Migrate web app from Next.js to Vite + TanStack Router SPA (#6)

* Convert to Turborepo monorepo with shared API contract package

Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).

- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract standalone Hono API server and split shared packages

Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.

- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
  oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
  moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
  constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
  moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
  from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
  health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
  all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
  `INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure

* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog

Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.

Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract @sofa/logger and @sofa/config shared packages

- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
  extracted from `@sofa/db/logger`; update all imports across server,
  core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
  package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
  `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
  scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
  fast path in `index.ts` for cached files before falling back to the
  TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
  `ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
  the oRPC client (always same-origin in both dev and production)

* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution

- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
  return 404 for missing asset paths instead of falling back to
  `index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
  error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
  relative paths work regardless of the process working directory

* Migrate @sofa/logger to pino for structured logging

- Replace custom logger implementation in `packages/logger/` with pino
  + pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
  error directly instead of wrapping it in `{ error }` to match pino's
  serializer expectations

* Rename discoverProcedure/statsProcedure exports to discover/stats

* Add TanStackDevtools unified panel and VS Code workspace config

- Replace separate Router/Query devtools with unified `TanStackDevtools`
  from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
  `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)

* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml

Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.

Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix devtools plugin order and whitespace-only TMDB token check

Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:50:34 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 26558b29e4
commit a326c968b7
325 changed files with 26970 additions and 28519 deletions
+285
View File
@@ -0,0 +1,285 @@
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";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient, signIn, signUp } from "@/lib/auth/client";
export interface AuthConfig {
oidcEnabled: boolean;
oidcProviderName: string | null;
passwordLoginDisabled: boolean;
registrationOpen?: boolean;
}
const authInputClass =
"h-11 rounded-lg border-border/50 bg-background/50 px-4 py-0 placeholder:text-muted-foreground/50 focus-visible:border-primary/40 focus-visible:ring-ring md:text-sm";
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
},
};
export function AuthForm({
mode,
authConfig,
}: {
mode: "login" | "register";
authConfig?: AuthConfig;
}) {
const navigate = useNavigate();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const isRegister = mode === "register";
const showOidc = authConfig?.oidcEnabled ?? false;
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
if (isRegister) {
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? "Registration failed");
return;
}
} else {
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? "Login failed");
return;
}
}
void navigate({ to: "/dashboard" });
} catch {
setError("Something went wrong");
} finally {
setLoading(false);
}
}
async function handleOidcLogin() {
setError("");
setOidcLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/dashboard",
});
} catch {
setError("Failed to start SSO login");
} finally {
setOidcLoading(false);
}
}
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" />
<motion.div
className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 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">
<SofaLogo className="size-9" />
</Link>
<h1 className="text-balance font-medium text-lg">
{isRegister ? "Create your account" : "Welcome back"}
</h1>
<p className="text-muted-foreground text-sm">
{isRegister ? "Start tracking your watches" : "Sign in to continue"}
</p>
</div>
{showOidc && (
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
}}
>
<motion.div variants={fieldVariants}>
<Button
type="button"
variant="outline"
onClick={handleOidcLogin}
disabled={oidcLoading}
className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground"
>
<IconKey aria-hidden={true} className="size-4" />
{oidcLoading
? "Redirecting…"
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
</Button>
</motion.div>
</motion.div>
)}
{showOidc && showPasswordForm && (
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border/50" />
<span className="text-muted-foreground text-xs">or</span>
<div className="h-px flex-1 bg-border/50" />
</div>
)}
{showPasswordForm && (
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
}}
>
{isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="name"
className="text-muted-foreground uppercase tracking-wider"
>
Name
</Label>
<Input
id="name"
type="text"
required
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={authInputClass}
placeholder="Your name…"
/>
</motion.div>
)}
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="email"
className="text-muted-foreground uppercase tracking-wider"
>
Email
</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
spellCheck={false}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={authInputClass}
placeholder="wwhite@graymatter.biz"
/>
</motion.div>
<motion.div variants={fieldVariants} className="space-y-1.5">
<Label
htmlFor="password"
className="text-muted-foreground uppercase tracking-wider"
>
Password
</Label>
<Input
id="password"
type="password"
required
minLength={8}
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={authInputClass}
placeholder="Min 8 characters…"
/>
</motion.div>
<motion.div variants={fieldVariants}>
<Button
type="submit"
disabled={loading}
className="h-11 w-full rounded-lg text-sm hover:shadow-lg hover:shadow-primary/20"
>
{loading
? "Loading…"
: isRegister
? "Create account"
: "Sign in"}
</Button>
</motion.div>
</motion.form>
)}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<Alert variant="destructive" className="bg-destructive/10">
<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 ? (
<>
Already have an account?{" "}
<Link
to="/login"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
Sign in
</Link>
</>
) : (
<>
Don&apos;t have an account?{" "}
<Link
to="/register"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
Register
</Link>
</>
)}
</p>
)}
</motion.div>
</div>
);
}
+481
View File
@@ -0,0 +1,481 @@
import {
IconDeviceTv,
IconHome,
IconKeyboard,
IconMovie,
IconSearch,
IconUser,
IconX,
} from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebounce } from "@/hooks/use-debounce";
import {
commandPaletteOpenAtom,
helpOpenAtom,
MAX_RECENT,
recentSearchesAtom,
} from "@/lib/atoms/command-palette";
import { orpc } from "@/lib/orpc/client";
// Static shortcut descriptions for the help dialog.
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
const SHORTCUT_DESCRIPTIONS = [
{ scope: "Global", description: "Search", keys: ["/"] },
{ scope: "Global", description: "Keyboard shortcuts", keys: ["?"] },
{ scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] },
{ scope: "Navigation", description: "Go to explore", keys: ["g", "e"] },
{ scope: "Title", description: "Cycle status", keys: ["w"] },
{ scope: "Title", description: "Mark watched", keys: ["m"] },
{ scope: "Title", description: "Go back", keys: ["Escape"] },
{ scope: "Title", description: "Rate 1 star", keys: ["1"] },
{ scope: "Title", description: "Rate 2 stars", keys: ["2"] },
{ scope: "Title", description: "Rate 3 stars", keys: ["3"] },
{ scope: "Title", description: "Rate 4 stars", keys: ["4"] },
{ scope: "Title", description: "Rate 5 stars", keys: ["5"] },
] as const;
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);
}
interface SearchResult {
tmdbId: number;
type: "movie" | "tv" | "person";
title: string;
posterPath?: string | null;
profilePath?: string | null;
releaseDate?: string | null;
voteAverage?: number;
knownFor?: string[];
knownForDepartment?: string;
}
export function CommandPalette() {
const navigate = useNavigate();
const progress = useProgress();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
commandPaletteOpenAtom,
);
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const trimmedQuery = debouncedQuery.trim();
const { data: searchData, isLoading: loading } = useQuery(
orpc.search.queryOptions({
input: trimmedQuery ? { query: trimmedQuery } : skipToken,
}),
);
const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? [];
const enabled = !commandPaletteOpen;
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
useHotkey("/", () => setCommandPaletteOpen(true), { enabled });
useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled });
useHotkeySequence(
["G", "H"],
() => {
progress.start();
void navigate({ to: "/dashboard" });
},
{ enabled, timeout: 500 },
);
useHotkeySequence(
["G", "E"],
() => {
progress.start();
void navigate({ to: "/explore" });
},
{ enabled, timeout: 500 },
);
// Reset query when palette opens
useEffect(() => {
if (commandPaletteOpen) {
setQuery("");
}
}, [commandPaletteOpen]);
// Save to recent searches after user stops typing for a while
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const trimmed = debouncedQuery.trim();
if (trimmed && results.length > 0) {
saveTimerRef.current = setTimeout(() => {
setRecentSearches((prev) => {
const filtered = prev.filter((q) => q !== trimmed);
return [trimmed, ...filtered].slice(0, MAX_RECENT);
});
}, 2000);
}
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [debouncedQuery, results.length, setRecentSearches]);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/people/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load person");
},
}),
);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const handleSelect = useCallback(
(result: SearchResult) => {
setCommandPaletteOpen(false);
progress.start();
if (result.type === "person") {
resolvePersonMutation.mutate({ tmdbId: result.tmdbId });
} else {
resolveTitleMutation.mutate({
tmdbId: result.tmdbId,
type: result.type,
});
}
},
[
setCommandPaletteOpen,
progress,
resolvePersonMutation,
resolveTitleMutation,
],
);
const handleRecentSearch = useCallback((q: string) => {
setQuery(q);
}, []);
const handleRemoveRecent = useCallback(
(q: string) => {
setRecentSearches((prev) => prev.filter((s) => s !== q));
},
[setRecentSearches],
);
const handleClearRecent = useCallback(() => {
setRecentSearches([]);
}, [setRecentSearches]);
const hasQuery = query.trim().length > 0;
return (
<>
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle>
<DialogDescription>
Search for movies, TV shows, or run commands
</DialogDescription>
</DialogHeader>
<DialogContent
className="top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-lg"
showCloseButton={false}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search movies & TV shows…"
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-80">
{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">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
)}
{hasQuery && !loading && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{hasQuery && !loading && results.length > 0 && (
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
{r.type === "person" ? (
<div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted">
{r.profilePath ? (
<img
src={r.profilePath}
alt={r.title}
width={40}
height={40}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<IconUser
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
</div>
)}
</div>
) : (
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
{r.posterPath ? (
<img
src={r.posterPath}
alt={r.title}
width={32}
height={48}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
?
</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">
{r.type === "person" ? (
<IconUser
aria-hidden={true}
className="size-[11px]"
/>
) : r.type === "movie" ? (
<IconMovie
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>
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{!hasQuery && (
<>
{recentSearches.length > 0 && (
<CommandGroup
heading={
<div className="flex items-center justify-between">
<span>Recent Searches</span>
<button
type="button"
onClick={handleClearRecent}
className="font-normal text-[10px] text-muted-foreground transition-colors hover:text-foreground"
>
Clear all
</button>
</div>
}
>
{recentSearches.map((q) => (
<CommandItem
key={q}
onSelect={() => handleRecentSearch(q)}
className="group"
>
<IconSearch
aria-hidden={true}
className="size-3.5 text-muted-foreground"
/>
<span className="flex-1">{q}</span>
<span
data-slot="command-shortcut"
className="ml-auto"
>
<button
type="button"
aria-label="Remove from recent searches"
onClick={(e) => {
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"
>
<IconX className="size-3" />
</button>
</span>
</CommandItem>
))}
</CommandGroup>
)}
{recentSearches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Quick Actions">
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
progress.start();
void navigate({ to: "/dashboard" });
}}
>
<IconHome aria-hidden={true} className="size-3.5" />
Go to Dashboard
<CommandShortcut>G H</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
progress.start();
void navigate({ to: "/explore" });
}}
>
<IconSearch aria-hidden={true} className="size-3.5" />
Go to Explore
<CommandShortcut>G E</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
setHelpOpen(true);
}}
>
<IconKeyboard aria-hidden={true} className="size-3.5" />
Keyboard Shortcuts
<CommandShortcut>?</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<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">
{scope}
</h3>
<div className="space-y-1">
{items.map((item) => (
<div
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>
<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>
)}
<Kbd>{formatKey(key)}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
function formatKey(key: string): string {
const map: Record<string, string> = {
" ": "Space",
Escape: "Esc",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
};
return map[key] ?? key.toUpperCase();
}
@@ -0,0 +1,94 @@
import { IconPlayerPlay } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
export interface ContinueWatchingItemProps {
title: {
id: string;
title: string;
backdropPath: string | null;
};
nextEpisode: {
seasonNumber: number;
episodeNumber: number;
name: string | null;
stillPath: string | null;
} | null;
totalEpisodes: number;
watchedEpisodes: number;
}
export function ContinueWatchingCard({
item,
}: {
item: ContinueWatchingItemProps;
}) {
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"
>
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
{stillUrl ? (
<img
src={stillUrl}
alt={item.nextEpisode?.name ?? item.title.title}
loading="lazy"
decoding="async"
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>
)}
<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" />
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}
</span>{" "}
{item.nextEpisode.name}
</p>
</div>
)}
</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="text-muted-foreground text-xs">
{item.watchedEpisodes}/{item.totalEpisodes} 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">
<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>
)}
</Link>
);
}
@@ -0,0 +1,65 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
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">
<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">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
</div>
</div>
);
}
export function ContinueWatchingSectionSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-40" />
</div>
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
</div>
</div>
);
}
export function ContinueWatchingList({
items,
}: {
items: ContinueWatchingItemProps[];
}) {
return (
<ScrollArea
scrollFade
hideScrollbar
className="-mx-4 sm:-mx-0 [&_[data-slot=scroll-area-content]]:px-px"
>
<div className="flex gap-4 px-4 py-2 sm:px-0">
{items.map((item, i) => (
<div key={item.title.id} className="shrink-0">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<ContinueWatchingCard item={item} />
</div>
</div>
))}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,28 @@
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 { FeedSection } from "./feed-section";
export function ContinueWatchingSection() {
const { data, isPending } = useQuery(
orpc.dashboard.continueWatching.queryOptions(),
);
if (isPending) return <ContinueWatchingSectionSkeleton />;
const items = data?.items ?? [];
if (items.length === 0) return null;
return (
<FeedSection
title="Continue Watching"
icon={<IconPlayerPlay className="size-5 text-primary" />}
>
<ContinueWatchingList items={items} />
</FeedSection>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
export function FeedSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<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>
</div>
{children}
</section>
);
}
@@ -0,0 +1,23 @@
import { IconBooks } 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 LibrarySection() {
const { data, isPending } = useQuery(orpc.dashboard.library.queryOptions());
if (isPending) return <TitleGridSectionSkeleton />;
const items = data?.items ?? [];
if (items.length === 0) return null;
return (
<FeedSection
title="In Your Library"
icon={<IconBooks className="size-5 text-primary" />}
>
<TitleGrid items={items} />
</FeedSection>
);
}
@@ -0,0 +1,25 @@
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(),
);
if (isPending) return <TitleGridSectionSkeleton />;
const items = data?.items ?? [];
if (items.length === 0) return null;
return (
<FeedSection
title="Recommended for You"
icon={<IconThumbUp className="size-5 text-primary" />}
>
<TitleGrid items={items} />
</FeedSection>
);
}
@@ -0,0 +1,64 @@
import { useEffect, useId, useRef, useState } from "react";
import { Area, AreaChart, YAxis } from "recharts";
interface SparklineProps {
data: Array<{ bucket: string; count: number }>;
color: string;
}
export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
setSize({ width, height });
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
if (!data.some((d) => d.count > 0)) return null;
return (
<div
ref={containerRef}
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
>
{size.width > 0 && size.height > 0 && (
<AreaChart
data={data}
width={size.width}
height={size.height}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.08} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={[0, "auto"]} hide />
<Area
type="monotone"
dataKey="count"
stroke="currentColor"
strokeWidth={1}
strokeOpacity={0.15}
fill={`url(#${gradientId})`}
isAnimationActive={false}
/>
</AreaChart>
)}
</div>
);
}
@@ -0,0 +1,215 @@
import type {
DashboardStats,
HistoryBucket,
TimePeriod,
} from "@sofa/api/schemas";
import {
IconCheck,
IconLibrary,
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import { Sparkline } from "./sparkline";
function StatCardSkeleton() {
return (
<div className="overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-md" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="mt-2 h-7 w-12" />
</div>
);
}
export function StatsSectionSkeleton() {
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
);
}
const periodLabels: Record<TimePeriod, string> = {
today: "Today",
this_week: "This Week",
this_month: "This Month",
this_year: "This Year",
};
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
interface StatCardProps {
icon: React.ComponentType<{ className?: string }>;
color: string;
bgColor: string;
value: number;
index: number;
label: React.ReactNode;
loading?: boolean;
sparklineData?: HistoryBucket[];
}
function StatCard({
icon: Icon,
color,
bgColor,
value,
index,
label,
sparklineData,
}: StatCardProps) {
return (
<div
className="relative animate-stagger-item overflow-hidden rounded-xl border border-border/30 bg-card/50 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}`}
>
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
</div>
<span className="font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
{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`}
>
{value}
</p>
</div>
);
}
const inlineTriggerClass =
"h-auto w-auto gap-0.5 rounded-none border-0 bg-transparent py-1 px-0.5 -my-1 -mx-0.5 sm:p-0 sm:m-0 [font-size:inherit] [line-height:inherit] shadow-none underline decoration-dotted decoration-muted-foreground/50 underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground/50 focus-visible:ring-0 focus-visible:decoration-solid focus-visible:decoration-foreground dark:bg-transparent dark:hover:bg-transparent";
function PeriodSelector({
noun,
period,
onPeriodChange,
}: {
noun: string;
period: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
}) {
return (
<span className="inline-flex items-baseline gap-1">
{noun}{" "}
<Select
value={period}
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
modal={false}
>
<SelectTrigger
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
>
<SelectValue>
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{periods.map((p) => (
<SelectItem key={p} value={p}>
{periodLabels[p]}
</SelectItem>
))}
</SelectContent>
</Select>
</span>
);
}
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
const { data: movieStats } = useQuery(
orpc.stats.queryOptions({ input: { type: "movies", period: moviePeriod } }),
);
const { data: episodeStats } = useQuery(
orpc.stats.queryOptions({
input: { type: "episodes", period: episodePeriod },
}),
);
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
const movieHistory = movieStats?.history;
const episodeCount = episodeStats?.count ?? stats.episodesThisWeek;
const episodeHistory = episodeStats?.history;
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
icon={IconMovie}
color="text-primary"
bgColor="bg-primary/10"
value={movieCount}
index={0}
sparklineData={movieHistory}
label={
<PeriodSelector
noun="Movies"
period={moviePeriod}
onPeriodChange={setMoviePeriod}
/>
}
/>
<StatCard
icon={IconPlayerPlay}
color="text-status-watching"
bgColor="bg-status-watching/10"
value={episodeCount}
index={1}
sparklineData={episodeHistory}
label={
<PeriodSelector
noun="Episodes"
period={episodePeriod}
onPeriodChange={setEpisodePeriod}
/>
}
/>
<StatCard
icon={IconLibrary}
color="text-status-watchlist"
bgColor="bg-status-watchlist/10"
value={stats.librarySize}
index={2}
label="In Library"
/>
<StatCard
icon={IconCheck}
color="text-status-completed"
bgColor="bg-status-completed/10"
value={stats.completed}
index={3}
label="Completed"
/>
</div>
);
}
@@ -0,0 +1,45 @@
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(),
);
if (isPending) return <StatsSectionSkeleton />;
if (!stats) return null;
const isEmpty =
stats.moviesThisMonth === 0 &&
stats.episodesThisWeek === 0 &&
stats.librarySize === 0 &&
stats.completed === 0;
return (
<>
<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>
<div className="space-y-1">
<p className="font-medium">Your library is empty</p>
<p className="text-muted-foreground text-sm">
Search for movies and TV shows to start tracking
</p>
</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"
>
Start exploring
</Link>
</div>
)}
</>
);
}
@@ -0,0 +1,56 @@
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Skeleton } from "@/components/ui/skeleton";
interface TitleGridItem {
id: string;
tmdbId: number;
type: string;
title: string;
posterPath: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: "watchlist" | "in_progress" | "completed" | null;
}
export function TitleGridSectionSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-32" />
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
</div>
</div>
);
}
export function TitleGrid({ items }: { items: TitleGridItem[] }) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.map((t, i) => (
<div
key={t.id}
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
id={t.id}
tmdbId={t.tmdbId}
type={t.type}
title={t.title}
posterPath={t.posterPath}
releaseDate={t.releaseDate}
voteAverage={t.voteAverage}
userStatus={t.userStatus}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,12 @@
export function WelcomeHeader({ name }: { name?: string | null }) {
return (
<div>
<h1 className="text-balance font-display text-3xl tracking-tight">
Welcome back{name ? `, ${name}` : ""}
</h1>
<p className="mt-1 text-muted-foreground text-sm">
Here&apos;s what&apos;s happening with your library
</p>
</div>
);
}
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ExpandableTextProps {
text: string;
/** Tailwind line-clamp class applied when collapsed (default: "line-clamp-3") */
clampClass?: string;
className?: string;
textClassName?: string;
}
export function ExpandableText({
text,
clampClass = "line-clamp-3",
className,
textClassName,
}: ExpandableTextProps) {
const [expanded, setExpanded] = useState(false);
const [clamped, setClamped] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => setClamped(el.scrollHeight > el.clientHeight + 1);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className={className}>
<p
ref={ref}
className={cn(
"break-words text-muted-foreground leading-relaxed",
!expanded && clampClass,
textClassName,
)}
>
{text}
</p>
{(clamped || expanded) && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80"
>
{expanded ? "Show less" : "Read more"}
</button>
)}
</div>
);
}
@@ -0,0 +1,113 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
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";
function ExploreSkeletons() {
return (
<div className="space-y-10">
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-[320px] rounded-none" />
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-32" />
</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]"
>
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
</div>
))}
</div>
</div>
))}
</div>
);
}
export function ExploreClient() {
const { data: trending, isPending: trendingPending } = useQuery(
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
);
const { data: popularMovies, isPending: moviesPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
const { data: popularTv, isPending: tvPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
const { data: movieGenreData } = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
);
const { data: tvGenreData } = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
);
const isPending = trendingPending || moviesPending || tvPending;
if (isPending) return <ExploreSkeletons />;
// Merge user statuses and episode progress from all responses
const userStatuses = {
...trending?.userStatuses,
...popularMovies?.userStatuses,
...popularTv?.userStatuses,
};
const episodeProgress = {
...trending?.episodeProgress,
...popularMovies?.episodeProgress,
...popularTv?.episodeProgress,
};
return (
<div className="space-y-10">
{trending?.hero && (
<HeroBanner
tmdbId={trending.hero.tmdbId}
type={trending.hero.type}
title={trending.hero.title}
overview={trending.hero.overview}
backdropPath={trending.hero.backdropPath}
voteAverage={trending.hero.voteAverage}
/>
)}
<TitleRow
heading="Trending Today"
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
items={(trending?.items ?? []).slice(0, 20)}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<FilterableTitleRow
heading="Popular Movies"
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
mediaType="movie"
defaultItems={(popularMovies?.items ?? []).slice(0, 20)}
genres={movieGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<FilterableTitleRow
heading="Popular TV Shows"
icon={
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
}
mediaType="tv"
defaultItems={(popularTv?.items ?? []).slice(0, 20)}
genres={tvGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
</div>
);
}
@@ -0,0 +1,158 @@
import { skipToken, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { orpc } from "@/lib/orpc/client";
interface Genre {
id: number;
name: string;
}
interface TitleRowItem {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface FilterableTitleRowProps {
heading: string;
icon: React.ReactNode;
mediaType: "movie" | "tv";
defaultItems: TitleRowItem[];
genres: Genre[];
userStatuses?: Record<string, TitleStatus>;
episodeProgress?: Record<string, { watched: number; total: number }>;
}
export function FilterableTitleRow({
heading,
icon,
mediaType,
defaultItems,
genres,
userStatuses: initialStatuses = {},
episodeProgress: initialProgress = {},
}: FilterableTitleRowProps) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const { data: discoverData, isLoading: isPending } = useQuery(
orpc.discover.queryOptions({
input:
selectedGenre != null
? { mediaType, genreId: selectedGenre }
: skipToken,
}),
);
const items =
selectedGenre === null ? defaultItems : (discoverData?.items ?? []);
const userStatuses =
selectedGenre === null
? initialStatuses
: (discoverData?.userStatuses ?? {});
const episodeProgress =
selectedGenre === null
? initialProgress
: (discoverData?.episodeProgress ?? {});
function toggleGenre(genreId: number) {
setSelectedGenre(genreId === selectedGenre ? null : genreId);
}
return (
<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>
</div>
{/* Genre chips */}
<ScrollArea scrollFade hideScrollbar>
<div className="flex gap-2">
{genres.map((genre) => (
<Button
key={genre.id}
variant={selectedGenre === genre.id ? "default" : "outline"}
size="sm"
onClick={() => toggleGenre(genre.id)}
className={`shrink-0 rounded-full ${
selectedGenre === genre.id
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
}`}
>
{genre.name}
</Button>
))}
</div>
</ScrollArea>
{/* Loading skeleton */}
{isPending && (
<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]"
>
<TitleCardSkeleton />
</div>
))}
</div>
)}
{/* Empty state */}
{!isPending && selectedGenre !== null && items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm">
No titles found for this genre.
</p>
)}
{/* Title cards */}
{!isPending && items.length > 0 && (
<ScrollArea
key={selectedGenre ?? "default"}
scrollFade
hideScrollbar
className="-mx-6 sm:-mx-2"
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.slice(0, 20).map((item: TitleRowItem, i: number) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
type={item.type}
title={item.title}
posterPath={item.posterPath}
releaseDate={item.releaseDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
/>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</section>
);
}
@@ -0,0 +1,135 @@
import {
IconDeviceTv,
IconMovie,
IconPlus,
IconStar,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { orpc } from "@/lib/orpc/client";
interface HeroBannerProps {
tmdbId: number;
type: "movie" | "tv";
title: string;
overview: string;
backdropPath: string | null;
voteAverage: number;
}
export function HeroBanner({
tmdbId,
type,
title,
overview,
backdropPath,
voteAverage,
}: HeroBannerProps) {
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
function handleNavigate() {
if (resolveMutation.isPending) return;
progress.start();
resolveMutation.mutate({ tmdbId, type });
}
return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
{backdropPath ? (
<img
src={backdropPath}
alt={title}
loading="eager"
decoding="async"
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" />
)}
{/* 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" />
{/* Content */}
<div className="absolute inset-0 flex items-end">
<div className="w-full pb-8">
<div className="mx-auto max-w-6xl pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]">
<div
className="animate-stagger-item"
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">
{type === "movie" ? (
<>
<IconMovie aria-hidden className="size-3.5" />
Movie
</>
) : (
<>
<IconDeviceTv aria-hidden className="size-3.5" />
TV
</>
)}
</span>
{voteAverage > 0 && (
<span className="flex items-center gap-1 text-primary text-sm">
<IconStar
aria-hidden={true}
className="size-3.5 fill-primary"
/>
{voteAverage.toFixed(1)}
</span>
)}
<span className="text-muted-foreground text-xs">
Trending today
</span>
</div>
<button
type="button"
className="group/title cursor-pointer text-left"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title}
</h2>
</button>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview}
</p>
<button
type="button"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
className="mt-4 inline-flex h-9 cursor-pointer 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 disabled:opacity-70"
>
<IconPlus aria-hidden={true} className="size-4" />
Add to Library
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { TitleCard } from "@/components/title-card";
import { ScrollArea } from "@/components/ui/scroll-area";
interface TitleRowItem {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}
interface TitleRowProps {
heading: string;
icon: React.ReactNode;
items: TitleRowItem[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
}
export function TitleRow({
heading,
icon,
items,
userStatuses,
episodeProgress,
}: TitleRowProps) {
if (items.length === 0) return null;
return (
<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>
</div>
<ScrollArea scrollFade hideScrollbar className="-mx-6 sm:-mx-2">
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
type={item.type}
title={item.title}
posterPath={item.posterPath}
releaseDate={item.releaseDate}
voteAverage={item.voteAverage}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
/>
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
+181
View File
@@ -0,0 +1,181 @@
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
const posterLayout = [
// Left column
{ x: "8%", y: "5%", rotate: -8, delay: 0 },
{ x: "5%", y: "38%", rotate: -12, delay: 0.1 },
{ x: "10%", y: "68%", rotate: -6, delay: 0.2 },
// Left-center column
{ x: "24%", y: "12%", rotate: 4, delay: 0.05 },
{ x: "22%", y: "50%", rotate: -3, delay: 0.15 },
{ x: "26%", y: "78%", rotate: 6, delay: 0.25 },
// Right-center column
{ x: "62%", y: "8%", rotate: -5, delay: 0.08 },
{ x: "64%", y: "42%", rotate: 7, delay: 0.18 },
{ x: "60%", y: "72%", rotate: -4, delay: 0.28 },
// Right column
{ x: "80%", y: "3%", rotate: 10, delay: 0.03 },
{ x: "82%", y: "36%", rotate: -8, delay: 0.13 },
{ x: "78%", y: "66%", rotate: 5, delay: 0.23 },
];
export function LandingPage({
posterUrls,
freshInstall,
registrationOpen,
}: {
posterUrls: string[];
freshInstall: boolean;
registrationOpen: boolean;
}) {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden">
{/* Background grain texture */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.03]"
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")`,
}}
/>
{/* Floating poster collage */}
<div className="pointer-events-none absolute inset-0 hidden overflow-hidden sm:block">
{posterUrls.map((url, i) => {
const pos = posterLayout[i];
return (
<motion.div
key={url}
className="absolute w-28 md:w-32 lg:w-36"
style={{ left: pos.x, top: pos.y }}
initial={{ opacity: 0, scale: 0.8, rotate: pos.rotate }}
animate={{ opacity: 0.12, scale: 1, rotate: pos.rotate }}
transition={{
type: "spring" as const,
stiffness: 100,
damping: 20,
delay: 0.4 + pos.delay,
}}
>
<div className="overflow-hidden rounded-xl shadow-lg">
<img
src={url}
alt=""
width={300}
height={450}
loading="lazy"
decoding="async"
className="h-auto w-full"
/>
</div>
</motion.div>
);
})}
</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" />
{/* 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]"
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 6,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<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]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
}}
>
Self-hosted movie & TV tracker
</motion.p>
<motion.div
className="flex justify-center text-primary"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
delay: 0.1,
}}
>
<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"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
delay: 0.2,
}}
>
Track what you watch. Know what&apos;s next.
<br />
Your library, your data, your rules.
</motion.p>
</div>
<motion.div
className="flex gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
delay: 0.35,
}}
>
{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"
>
<span className="relative z-10">Get Started</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
) : (
<>
<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"
>
<span className="relative z-10">Sign In</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
{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"
>
Register
</Link>
)}
</>
)}
</motion.div>
</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>
);
}
+345
View File
@@ -0,0 +1,345 @@
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";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
import { signOut } from "@/lib/auth/client";
/** Horizontal inset (px) of the desktop indicator within each link (Tailwind `inset-x-2`). */
const DESKTOP_INDICATOR_INSET = 8;
/** Width (px) of the mobile indicator bar (Tailwind `w-8` = 2rem). */
const MOBILE_INDICATOR_WIDTH = 32;
const springTransition = {
type: "spring",
stiffness: 380,
damping: 30,
} as const;
const navLinks = [
{ href: "/dashboard", label: "Home" },
{ href: "/explore", label: "Explore" },
] as const;
const mobileTabs = [
{ href: "/dashboard", label: "Home", icon: IconHome },
{ href: "/explore", label: "Explore", icon: IconCompass },
{ href: "/settings", label: "Settings", icon: IconSettings },
] as const;
function isLinkActive(pathname: string, href: string) {
return (
pathname === href ||
pathname.startsWith(`${href}/`) ||
(href === "/dashboard" && pathname === "/")
);
}
function measureDesktopIndicator(itemRect: DOMRect, containerRect: DOMRect) {
return {
left: itemRect.left - containerRect.left + DESKTOP_INDICATOR_INSET,
width: itemRect.width - DESKTOP_INDICATOR_INSET * 2,
};
}
function measureMobileIndicator(itemRect: DOMRect, containerRect: DOMRect) {
const center = itemRect.left + itemRect.width / 2 - containerRect.left;
return center - MOBILE_INDICATOR_WIDTH / 2;
}
/**
* Tracks the active navigation item's position, recalculating on resize
* and visibility changes across breakpoints.
*/
function useActiveIndicator<T>(
activeIndex: number,
containerRef: React.RefObject<HTMLElement | null>,
itemRefs: React.MutableRefObject<(HTMLElement | null)[]>,
measure: (itemRect: DOMRect, containerRect: DOMRect) => T,
): { value: T | null; instant: boolean } {
const [value, setValue] = useState<T | null>(null);
const instantRef = useRef(true);
useLayoutEffect(() => {
instantRef.current = false;
const update = () => {
if (activeIndex === -1) {
setValue(null);
return;
}
const item = itemRefs.current[activeIndex];
const container = containerRef.current;
if (item && container && container.offsetWidth > 0) {
setValue(
measure(
item.getBoundingClientRect(),
container.getBoundingClientRect(),
),
);
} else {
setValue(null);
}
};
update();
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
instantRef.current = true;
update();
});
observer.observe(container);
return () => observer.disconnect();
}, [activeIndex, containerRef, itemRefs, measure]);
return { value, instant: instantRef.current };
}
export function NavBar({
userName,
userEmail,
userImage,
userRole,
}: {
userName: string;
userEmail: string;
userImage?: string;
userRole?: string;
}) {
const navigate = useNavigate();
const { pathname } = useLocation();
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
const initial = userName?.charAt(0).toUpperCase() ?? "?";
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(
activeIndex,
navRef,
linkRefs,
measureDesktopIndicator,
);
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">
<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"
>
<SofaLogo className="size-7" />
</Link>
<nav
ref={navRef}
aria-label="Primary"
className="relative hidden items-center gap-1 sm:flex"
>
{navLinks.map((link, i) => {
const isActive = isLinkActive(pathname, link.href);
return (
<Link
key={link.href}
ref={(el) => {
linkRefs.current[i] = el;
}}
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"
>
{link.label}
</Link>
);
})}
{indicator && (
<motion.div
className="absolute -bottom-[11px] h-0.5 rounded-full bg-primary"
initial={false}
animate={{ left: indicator.left, width: indicator.width }}
transition={desktopInstant ? { duration: 0 } : springTransition}
/>
)}
</nav>
</div>
<div className="flex flex-1 items-center justify-end gap-2 sm:flex-none sm:gap-1">
{/* Mobile search trigger */}
<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"
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>Search</span>
</button>
{/* Desktop search trigger pill */}
<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"
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>Search</span>
<Kbd className="ml-2.5"> K</Kbd>
</button>
<Separator
orientation="vertical"
className="mx-1.5 my-auto hidden h-6 bg-border/50 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"
aria-label="Account menu"
>
<Avatar>
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-primary text-xs">
{initial}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-56">
<div className="flex items-center gap-3 px-2 py-2.5">
<Avatar className="size-9">
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-primary text-sm">
{initial}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm leading-tight">
{userName}
{userRole === "admin" && (
<Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
Admin
</Badge>
)}
</p>
<p className="truncate text-muted-foreground text-xs">
{userEmail}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link to="/settings" />}
className="cursor-pointer text-[13px]"
>
<IconSettings className="size-3.5" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={async () => {
await signOut();
void navigate({ to: "/" });
}}
className="cursor-pointer text-[13px]"
>
<IconLogout className="size-3.5" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Mobile: simple avatar link to settings */}
<Link
to="/settings"
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
aria-label="Settings"
>
<Avatar size="sm">
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-[10px] text-primary">
{initial}
</AvatarFallback>
</Avatar>
</Link>
</div>
</div>
</header>
);
}
export function MobileTabBar() {
const { pathname } = useLocation();
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(
activeIndex,
containerRef,
tabRefs,
measureMobileIndicator,
);
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"
>
<div ref={containerRef} className="relative flex h-14 items-stretch">
{mobileTabs.map((tab, i) => {
const Icon = tab.icon;
const isActive = isLinkActive(pathname, tab.href);
return (
<Link
key={tab.href}
ref={(el) => {
tabRefs.current[i] = el;
}}
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"
>
<Icon
className={`size-5 ${isActive ? "text-primary" : "text-muted-foreground"}`}
/>
<span
className={`font-medium text-[10px] ${isActive ? "text-primary" : "text-muted-foreground"}`}
>
{tab.label}
</span>
</Link>
);
})}
{indicatorLeft !== null && (
<motion.div
className="absolute top-0 h-0.5 w-8 rounded-full bg-primary"
initial={false}
animate={{ left: indicatorLeft }}
transition={mobileInstant ? { duration: 0 } : springTransition}
/>
)}
</div>
{/* Safe area for devices with home indicator */}
<div className="h-[env(safe-area-inset-bottom)]" />
</nav>
);
}
@@ -0,0 +1,192 @@
import { useLocation } from "@tanstack/react-router";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from "react";
type ProgressApi = {
start: () => void;
done: () => void;
set: (pct: number) => void;
};
const ProgressContext = createContext<ProgressApi | null>(null);
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
export function ProgressProvider({ children }: { children: ReactNode }) {
const { pathname, searchStr } = useLocation();
const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(0);
const inFlightRef = useRef(false);
const showTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const trickleTimerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const finishTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const safetyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
function clearTimers() {
clearTimeout(showTimerRef.current);
clearInterval(trickleTimerRef.current);
clearTimeout(finishTimerRef.current);
clearTimeout(safetyTimerRef.current);
}
function start() {
if (inFlightRef.current) return;
inFlightRef.current = true;
clearTimers();
// Delay showing to avoid flash on instant (prefetched) navigations
showTimerRef.current = setTimeout(() => {
setVisible(true);
setProgress(8);
trickleTimerRef.current = setInterval(() => {
setProgress((p) => {
if (!inFlightRef.current) return p;
return clamp(p + Math.max(0.5, (90 - p) * 0.08), 0, 90);
});
}, 200);
}, 100);
// Safety timeout to prevent stuck bar
safetyTimerRef.current = setTimeout(() => {
inFlightRef.current = false;
clearTimers();
setVisible(false);
setProgress(0);
}, 12000);
}
function done() {
if (!inFlightRef.current) return;
inFlightRef.current = false;
clearTimers();
setProgress(100);
setVisible(true);
finishTimerRef.current = setTimeout(() => {
setVisible(false);
setTimeout(() => setProgress(0), 200);
}, 200);
}
function set(pct: number) {
const next = clamp(pct, 0, 100);
if (next >= 100) {
done();
return;
}
if (!inFlightRef.current) start();
setProgress(next);
}
// Effect events — always see latest closures, don't appear in deps
const onLinkClick = useEffectEvent((e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
const anchor = (e.target as Element)?.closest?.("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("#")) return;
try {
const url = new URL(href, window.location.href);
if (url.origin !== window.location.origin) return;
} catch {
return;
}
start();
});
const onPopState = useEffectEvent(() => start());
const onRouteChange = useEffectEvent(() => {
if (inFlightRef.current) done();
});
// Set up listeners once
useEffect(() => {
document.addEventListener("click", onLinkClick, true);
window.addEventListener("popstate", onPopState);
return () => {
document.removeEventListener("click", onLinkClick, true);
window.removeEventListener("popstate", onPopState);
};
}, []);
// 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;
return;
}
onRouteChange();
}, [routeKey]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
clearTimeout(showTimerRef.current);
clearInterval(trickleTimerRef.current);
clearTimeout(finishTimerRef.current);
clearTimeout(safetyTimerRef.current);
};
}, []);
// Stable context API via ref indirection
const apiRef = useRef<ProgressApi>({ start, done, set });
apiRef.current = { start, done, set };
const api = useMemo<ProgressApi>(
() => ({
start: () => apiRef.current.start(),
done: () => apiRef.current.done(),
set: (pct) => apiRef.current.set(pct),
}),
[],
);
return (
<ProgressContext.Provider value={api}>
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 top-0 z-[10000] h-0.5 motion-safe:transition-opacity motion-safe:duration-200 motion-safe:ease-out"
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"}`}
style={{ transform: `scaleX(${clamp(progress, 0, 100) / 100})` }}
/>
</div>
{children}
</ProgressContext.Provider>
);
}
export function useProgress(): ProgressApi {
const ctx = useContext(ProgressContext);
if (!ctx) {
throw new Error("useProgress must be used inside <ProgressProvider>.");
}
return ctx;
}
@@ -0,0 +1,141 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Filter = "all" | "movie" | "tv";
type Sort = "newest" | "rating";
interface FilmographyGridProps {
credits: PersonCredit[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
}
export function FilmographyGrid({
credits,
userStatuses,
}: FilmographyGridProps) {
const [filter, setFilter] = useState<Filter>("all");
const [sort, setSort] = useState<Sort>("newest");
const filtered = useMemo(() => {
let list = credits;
if (filter !== "all") {
list = list.filter((c) => c.type === filter);
}
// Deduplicate by titleId (keep the first credit per title)
const seen = new Set<string>();
list = list.filter((c) => {
if (seen.has(c.titleId)) return false;
seen.add(c.titleId);
return true;
});
return [...list].sort((a, b) => {
if (sort === "rating") {
return (b.voteAverage ?? 0) - (a.voteAverage ?? 0);
}
const dateA = a.releaseDate ?? a.firstAirDate ?? "";
const dateB = b.releaseDate ?? b.firstAirDate ?? "";
return dateB.localeCompare(dateA);
});
}, [credits, filter, sort]);
if (credits.length === 0) return null;
const filters: { value: Filter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "movie", label: "Movies" },
{ value: "tv", label: "TV" },
];
return (
<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">
Filmography
</h2>
<span className="text-muted-foreground text-sm">
({filtered.length})
</span>
</div>
<Select
value={sort}
onValueChange={(v) => v && setSort(v as Sort)}
modal={false}
aria-label="Sort filmography"
>
<SelectTrigger size="sm">
<SelectValue>
{(value: string | null) =>
value === "newest"
? "Newest"
: value === "rating"
? "Rating"
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="end"
alignItemWithTrigger={false}
className="p-1"
>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{filters.map((f) => (
<Button
key={f.value}
variant={filter === f.value ? "default" : "secondary"}
size="sm"
onClick={() => setFilter(f.value)}
className="rounded-full"
>
{f.label}
</Button>
))}
</div>
<div
key={`${filter}-${sort}`}
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
>
{filtered.map((credit, i) => (
<div
key={credit.titleId}
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
id={credit.titleId}
tmdbId={credit.tmdbId}
type={credit.type}
title={credit.title}
posterPath={credit.posterPath}
releaseDate={credit.releaseDate ?? credit.firstAirDate}
voteAverage={credit.voteAverage}
userStatus={userStatuses?.[credit.titleId]}
/>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,54 @@
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import { FilmographyGrid } from "./filmography-grid";
import { PersonHero } from "./person-hero";
export function PersonDetailSkeleton() {
return (
<div className="space-y-10">
<div className="flex flex-col gap-6 sm:flex-row sm:gap-8">
<Skeleton className="size-40 shrink-0 self-center rounded-2xl sm:size-56 sm:self-start" />
<div className="flex-1 space-y-3">
<Skeleton className="h-9 w-2/3 sm:h-12" />
<Skeleton className="h-5 w-24 rounded-md" />
<div className="flex gap-4">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-36" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
</div>
</div>
);
}
export interface PersonDetailResponse {
person: ResolvedPerson;
filmography: PersonCredit[];
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
}
export function PersonDetailClient({ id }: { id: string }) {
const { data, isPending } = useQuery(
orpc.people.detail.queryOptions({ input: { id } }),
);
if (isPending) return <PersonDetailSkeleton />;
if (!data) return null;
return (
<div className="space-y-10">
<PersonHero person={data.person} />
<FilmographyGrid
credits={data.filmography}
userStatuses={data.userStatuses}
/>
</div>
);
}
@@ -0,0 +1,95 @@
import type { ResolvedPerson } from "@sofa/api/schemas";
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import { ExpandableText } from "@/components/expandable-text";
import { Badge } from "@/components/ui/badge";
interface PersonHeroProps {
person: ResolvedPerson;
}
function calculateAge(birthday: string, deathday?: string | null): number {
const birth = new Date(birthday);
const end = deathday ? new Date(deathday) : new Date();
let age = end.getFullYear() - birth.getFullYear();
const m = end.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) {
age--;
}
return age;
}
export function PersonHero({ person }: PersonHeroProps) {
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="size-40 shrink-0 self-center overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/10 sm:size-56 sm:self-start">
{person.profilePath ? (
<img
src={person.profilePath}
alt={person.name}
width={500}
height={500}
loading="eager"
decoding="async"
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">
{person.name.charAt(0)}
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-3">
<h1 className="text-balance font-display text-3xl tracking-tight 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">
{person.knownForDepartment === "Acting"
? "Actor"
: person.knownForDepartment === "Directing"
? "Director"
: person.knownForDepartment === "Writing"
? "Writer"
: person.knownForDepartment === "Production"
? "Producer"
: person.knownForDepartment === "Editing"
? "Editor"
: person.knownForDepartment}
</Badge>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground text-sm">
{person.birthday && (
<span className="flex items-center gap-1.5">
<IconCalendar aria-hidden={true} className="size-3.5" />
{format(parseISO(person.birthday), "MMMM d, yyyy")}
{age !== null && (
<span className="text-muted-foreground/60">
({person.deathday ? `died at ${age}` : `age ${age}`})
</span>
)}
</span>
)}
{person.placeOfBirth && (
<span className="flex items-center gap-1.5">
<IconMapPin aria-hidden={true} className="size-3.5" />
{person.placeOfBirth}
</span>
)}
</div>
{person.biography && <ExpandableText text={person.biography} />}
</div>
</div>
);
}
@@ -0,0 +1,334 @@
import {
IconCamera,
IconCheck,
IconLogout,
IconPencil,
IconTrash,
IconUser,
IconX,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
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 { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { signOut } from "@/lib/auth/client";
import { orpc } from "@/lib/orpc/client";
export function AccountSection({
user,
}: {
user: {
name: string;
email: string;
image?: string;
createdAt: string;
role?: string;
};
}) {
const navigate = useNavigate();
const router = useRouter();
const [avatarUrl, setAvatarUrl] = useState(user.image);
const [isHovered, setIsHovered] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Inline name editing
const [isEditingName, setIsEditingName] = useState(false);
const [displayName, setDisplayName] = useState(user.name);
const [editValue, setEditValue] = useState(user.name);
const nameInputRef = useRef<HTMLInputElement>(null);
const updateNameMutation = useMutation(
orpc.account.updateName.mutationOptions({
onSuccess: () => {
const trimmed = editValue.trim();
setDisplayName(trimmed);
setIsEditingName(false);
toast.success("Name updated");
router.invalidate();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Update failed";
toast.error(message);
},
}),
);
const isNamePending = updateNameMutation.isPending;
useEffect(() => {
if (isEditingName) {
nameInputRef.current?.focus();
nameInputRef.current?.select();
}
}, [isEditingName]);
const memberSince = new Date(user.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
});
const initial = displayName?.charAt(0).toUpperCase() ?? "?";
const uploadAvatarMutation = useMutation(
orpc.account.uploadAvatar.mutationOptions({
onSuccess: (data) => {
setAvatarUrl(data.imageUrl);
toast.success("Profile picture updated");
router.invalidate();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Upload failed";
toast.error(message);
},
onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = "";
},
}),
);
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
uploadAvatarMutation.mutate(file);
}
const removeAvatarMutation = useMutation(
orpc.account.removeAvatar.mutationOptions({
onSuccess: () => {
setAvatarUrl(undefined);
toast.success("Profile picture removed");
router.invalidate();
},
onError: () => {
toast.error("Failed to remove profile picture");
},
}),
);
const isAvatarPending =
uploadAvatarMutation.isPending || removeAvatarMutation.isPending;
function handleRemoveAvatar() {
removeAvatarMutation.mutate();
}
function handleNameSave() {
const trimmed = editValue.trim();
if (!trimmed || trimmed === displayName) {
setEditValue(displayName);
setIsEditingName(false);
return;
}
updateNameMutation.mutate({ name: trimmed });
}
function handleNameCancel() {
setEditValue(displayName);
setIsEditingName(false);
}
function handleNameKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
handleNameSave();
} else if (e.key === "Escape") {
handleNameCancel();
}
}
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">
Account
</h2>
</div>
<Card>
<CardContent className="flex items-center gap-4">
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
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 ? "Remove profile picture" : "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">
{initial}
</AvatarFallback>
</Avatar>
<AnimatePresence>
{(isHovered || isAvatarPending) && (
<motion.div
initial={{ opacity: 0 }}
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"
}`}
>
{isAvatarPending ? (
<Spinner className="size-4.5" />
) : avatarUrl ? (
<IconTrash className="size-4.5" />
) : (
<IconCamera className="size-4.5" />
)}
</motion.div>
)}
</AnimatePresence>
</TooltipTrigger>
<TooltipContent>
{avatarUrl ? "Remove picture" : "Upload picture"}
</TooltipContent>
</Tooltip>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleFileSelect}
/>
<div className="min-w-0 flex-1">
<CardTitle className="mb-0.5">
<AnimatePresence mode="wait" initial={false}>
{isEditingName ? (
<motion.div
key="editing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="flex items-center gap-1.5"
>
<div className="relative inline-grid items-center">
<span
className="invisible col-start-1 row-start-1 whitespace-pre font-medium text-sm"
aria-hidden="true"
>
{editValue || " "}
</span>
<input
ref={nameInputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleNameKeyDown}
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"
/>
</div>
{isNamePending ? (
<Spinner className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
handleNameSave();
}}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-primary"
aria-label="Save name"
>
<IconCheck className="size-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
handleNameCancel();
}}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-destructive"
aria-label="Cancel editing"
>
<IconX className="size-3.5" />
</button>
</>
)}
</motion.div>
) : (
<motion.button
key="display"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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"
>
{displayName}
<IconPencil className="size-3 text-transparent transition-colors group-hover/name:text-muted-foreground" />
</motion.button>
)}
</AnimatePresence>
</CardTitle>
<CardDescription>
{user.email}
{user.role === "admin" && (
<Badge className="ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
Admin
</Badge>
)}
</CardDescription>
<p className="mt-0.5 text-muted-foreground/60 text-xs">
Member since {memberSince}
</p>
</div>
<Button
variant="destructive"
onClick={async () => {
await signOut();
void navigate({ to: "/" });
}}
>
<IconLogout aria-hidden={true} />
Sign out
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,126 @@
import { IconCloudUpload } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { orpc } from "@/lib/orpc/client";
export function BackupRestoreSection() {
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const restoreMutation = useMutation(
orpc.admin.backups.restore.mutationOptions({
onSuccess: () => {
toast.success("Database restored. Reloading...");
setTimeout(() => window.location.reload(), 1500);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Restore failed";
toast.error(message);
},
onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = "";
},
}),
);
function handleRestore(file: File) {
restoreMutation.mutate(file);
}
return (
<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>
<div>
<CardTitle>Restore</CardTitle>
<CardDescription>
Upload a .db file to replace the current database. A safety backup
is created first.
</CardDescription>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".db"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setRestoreDialogOpen(true);
}
}}
/>
<AlertDialog
open={restoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore database?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setRestoreDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selectedFile) handleRestore(selectedFile);
setRestoreDialogOpen(false);
setSelectedFile(null);
}}
>
Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={restoreMutation.isPending}
>
{restoreMutation.isPending ? (
<Spinner />
) : (
<IconCloudUpload aria-hidden={true} />
)}
{restoreMutation.isPending ? "Restoring…" : "Upload"}
</Button>
</div>
</CardContent>
);
}
@@ -0,0 +1,424 @@
import type { BackupFrequency } from "@sofa/api/schemas";
import { IconCalendarWeek } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNow } from "date-fns";
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";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { orpc } from "@/lib/orpc/client";
const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
{ value: "6h", label: "6h" },
{ value: "12h", label: "12h" },
{ value: "1d", label: "1d" },
{ value: "7d", label: "7d" },
];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS_OF_WEEK = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
interface BackupScheduleState {
enabled: boolean;
maxRetention: number;
frequency: BackupFrequency;
time: string;
dow: number;
}
function getNextBackupDate(
frequency: BackupFrequency,
time: string,
dayOfWeek: number,
): Date {
const now = new Date();
const [h, m] = time.split(":").map(Number);
if (frequency === "6h") {
const next = new Date(now);
const currentHour = next.getHours();
const nextHour = Math.ceil((currentHour + 1) / 6) * 6;
next.setHours(nextHour, m, 0, 0);
if (next <= now) next.setHours(next.getHours() + 6);
return next;
}
if (frequency === "12h") {
const next = new Date(now);
const h2 = (h + 12) % 24;
const candidates = [h, h2].sort((a, b) => a - b);
for (const candidate of candidates) {
next.setHours(candidate, m, 0, 0);
if (next > now) return next;
}
next.setDate(next.getDate() + 1);
next.setHours(candidates[0], m, 0, 0);
return next;
}
if (frequency === "7d") {
const next = new Date(now);
const daysUntil = (dayOfWeek - next.getDay() + 7) % 7;
if (daysUntil === 0) {
next.setHours(h, m, 0, 0);
if (next > now) return next;
next.setDate(next.getDate() + 7);
next.setHours(h, m, 0, 0);
return next;
}
next.setDate(next.getDate() + daysUntil);
next.setHours(h, m, 0, 0);
return next;
}
// 1d
const next = new Date(now);
next.setHours(h, m, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
function formatNextBackup(
frequency: BackupFrequency,
time: string,
dayOfWeek: number,
): string {
const next = getNextBackupDate(frequency, time, dayOfWeek);
return `Next backup ${formatDistanceToNow(next, { addSuffix: true })}`;
}
export function BackupScheduleSection() {
const {
data: scheduleData,
isPending,
isError,
} = useQuery(orpc.admin.backups.schedule.queryOptions());
const [schedule, setSchedule] = useState<BackupScheduleState | null>(null);
// Use local state if user has modified, else use query data
const current: BackupScheduleState = schedule ?? {
enabled: scheduleData?.enabled ?? false,
maxRetention: scheduleData?.maxRetention ?? 7,
frequency: (scheduleData?.frequency as BackupFrequency) ?? "1d",
time: scheduleData?.time ?? "02:00",
dow: scheduleData?.dayOfWeek ?? 0,
};
const { enabled, maxRetention, frequency, time, dow } = current;
const updateScheduleMutation = useMutation(
orpc.admin.backups.updateSchedule.mutationOptions({
onMutate: (input) => {
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.time !== undefined) patch.time = input.time;
if (input.dayOfWeek !== undefined) patch.dow = input.dayOfWeek;
setSchedule({ ...current, ...patch });
return { previous };
},
onError: (_, input, ctx) => {
if (ctx?.previous) setSchedule(ctx.previous);
if (input.enabled !== undefined) {
toast.error("Failed to update scheduled backup setting");
} else if (input.maxRetention !== undefined) {
toast.error("Failed to update retention setting");
} else {
toast.error("Failed to update schedule");
}
},
}),
);
const togglingSchedule =
updateScheduleMutation.isPending &&
updateScheduleMutation.variables?.enabled !== undefined;
const savingSchedule =
updateScheduleMutation.isPending &&
updateScheduleMutation.variables?.frequency !== undefined;
const toggleScheduled = useCallback(
(checked: boolean) => {
updateScheduleMutation.mutate(
{ enabled: checked },
{
onSuccess: () =>
toast.success(
checked
? "Scheduled backups enabled"
: "Scheduled backups disabled",
),
},
);
},
[updateScheduleMutation],
);
const changeMaxRetention = useCallback(
(value: number) => {
updateScheduleMutation.mutate({ maxRetention: value });
},
[updateScheduleMutation],
);
const changeSchedule = useCallback(
(newFrequency: BackupFrequency, newTime: string, newDow = current.dow) => {
updateScheduleMutation.mutate(
{
frequency: newFrequency,
time: newTime,
dayOfWeek: newDow,
},
{ onSuccess: () => toast.success("Schedule updated") },
);
},
[updateScheduleMutation, current.dow],
);
if (isPending) {
return <Skeleton className="h-20 w-full rounded-xl" />;
}
if (isError) {
return (
<CardContent>
<p className="text-muted-foreground text-sm">
Failed to load backup schedule settings.
</p>
</CardContent>
);
}
return (
<>
<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>
<div>
<CardTitle>Backup schedule</CardTitle>
<CardDescription>
{enabled ? (
<span
className="inline-flex flex-wrap items-baseline"
suppressHydrationWarning
>
{formatNextBackup(frequency, time, dow)}. Keeping{" "}
<Select
value={String(maxRetention)}
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">
<SelectValue>
{(value: string | null) =>
value === "0"
? "unlimited"
: value
? `last ${value}`
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 0 ? "unlimited" : `last ${n}`}
</SelectItem>
))}
</SelectContent>
</Select>{" "}
backups.
</span>
) : (
"Automatically back up your database on a schedule"
)}
</CardDescription>
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={toggleScheduled}
disabled={togglingSchedule}
aria-label="Toggle scheduled backups"
/>
</div>
</CardContent>
<AnimatePresence initial={false}>
{enabled && (
<CardContent className="border-border/30 border-t pt-4">
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<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">
Frequency
</span>
<ButtonGroup>
{FREQUENCY_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
disabled={savingSchedule}
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-border/50 bg-muted/30 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}
>
{opt.label}
</Button>
))}
</ButtonGroup>
</div>
{/* Day of week — shown for 7d only */}
<AnimatePresence initial={false}>
{frequency === "7d" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Day:{" "}
</span>
<Select
value={String(dow)}
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">
<SelectValue>
{(value: string | null) =>
value !== null
? DAYS_OF_WEEK[Number(value)]
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{DAYS_OF_WEEK.map((day, i) => (
<SelectItem key={day} value={String(i)}>
{day}
</SelectItem>
))}
</SelectContent>
</Select>
</motion.div>
)}
</AnimatePresence>
{/* Time selector — shown for 12h, 1d, 7d */}
<AnimatePresence initial={false}>
{frequency !== "6h" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
{frequency === "12h" ? "Starting at" : "Time:"}{" "}
</span>
<Select
value={time}
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">
<SelectValue>
{(value: string | null) =>
value
? format(
new Date(
2000,
0,
1,
Number(value.split(":")[0]),
0,
),
"h:mm a",
)
: null
}
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="p-1"
>
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
return (
<SelectItem key={h} value={val}>
{format(new Date(2000, 0, 1, h, 0), "h:mm a")}
</SelectItem>
);
})}
</SelectContent>
</Select>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</CardContent>
)}
</AnimatePresence>
</>
);
}
@@ -0,0 +1,278 @@
import type { BackupInfo } from "@sofa/api/schemas";
import {
IconClock,
IconCloudDownload,
IconDatabaseExport,
IconPlus,
IconPointer,
IconShieldCheck,
IconTrash,
} from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
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 { 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`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupDate(dateStr: string): string {
return format(new Date(dateStr), "MMM d, h:mm a");
}
export function BackupSection() {
const { data } = useQuery(orpc.admin.backups.list.queryOptions());
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
// Use local state if user has modified, else use query data
const displayBackups = backups ?? data?.backups ?? [];
const createMutation = useMutation(
orpc.admin.backups.create.mutationOptions({
onSuccess: (backup) => {
setBackups((prev) => [
backup as BackupInfo,
...(prev ?? data?.backups ?? []).filter(
(b: BackupInfo) => b.filename !== backup.filename,
),
]);
toast.success("Backup created", {
action: {
label: "Download",
onClick: () => {
const a = document.createElement("a");
a.href = `/api/backup/${backup.filename}`;
a.download = backup.filename;
a.click();
},
},
});
},
onError: () => toast.error("Failed to create backup"),
}),
);
const deleteMutation = useMutation(
orpc.admin.backups.delete.mutationOptions({
onMutate: ({ filename }) => {
const previous = displayBackups;
setBackups(
displayBackups.filter((b: BackupInfo) => b.filename !== filename),
);
return { previous };
},
onSuccess: () => toast.success("Backup deleted"),
onError: (_, __, ctx) => {
if (ctx?.previous) setBackups(ctx.previous);
toast.error("Failed to delete backup");
},
}),
);
const creating = createMutation.isPending;
const deleting = deleteMutation.isPending
? (deleteMutation.variables?.filename ?? null)
: null;
return (
<>
{/* Header */}
<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>
<div>
<CardTitle>Database backups</CardTitle>
<CardDescription>
{displayBackups.length > 0
? `${displayBackups.length} backup${displayBackups.length !== 1 ? "s" : ""} stored`
: "No backups yet"}
</CardDescription>
</div>
</div>
<Button onClick={() => createMutation.mutate()} disabled={creating}>
{creating ? (
<Spinner className="size-3" />
) : (
<IconPlus aria-hidden={true} />
)}
{creating ? "Creating…" : "New backup"}
</Button>
</div>
</CardContent>
{/* Backup list */}
<AnimatePresence initial={false}>
{displayBackups.length > 0 && (
<CardContent className="border-border/30 border-t pt-4">
<div className="space-y-1.5">
{displayBackups.map((backup: BackupInfo) => (
<motion.div
key={backup.filename}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
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">
<Tooltip>
<TooltipTrigger
render={
<span className="flex shrink-0 items-center text-muted-foreground" />
}
>
{backup.source === "scheduled" ? (
<IconClock aria-hidden={true} className="size-3.5" />
) : backup.source === "pre-restore" ? (
<IconShieldCheck
aria-hidden={true}
className="size-3.5"
/>
) : (
<IconPointer
aria-hidden={true}
className="size-3.5"
/>
)}
</TooltipTrigger>
<TooltipContent>
{backup.source === "scheduled"
? "Scheduled backup"
: backup.source === "pre-restore"
? "Pre-restore backup"
: "Manual backup"}
</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-foreground text-xs">
{formatBackupDate(backup.createdAt)}
</span>
<span className="text-[11px] text-muted-foreground">
{formatBytes(backup.sizeBytes)}
</span>
<span
className="text-[11px] text-muted-foreground/50"
suppressHydrationWarning
>
{formatDistanceToNow(new Date(backup.createdAt), {
addSuffix: true,
})}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
nativeButton={false}
render={
<a
href={`/api/backup/${backup.filename}`}
download
aria-label="Download backup"
>
<IconCloudDownload />
</a>
}
/>
}
/>
<TooltipContent>Download</TooltipContent>
</Tooltip>
<AlertDialog>
<Tooltip>
<AlertDialogTrigger
render={
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete backup"
className="text-muted-foreground hover:text-destructive"
disabled={deleting === backup.filename}
/>
}
/>
}
>
{deleting === backup.filename ? (
<Spinner />
) : (
<IconTrash />
)}
</AlertDialogTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the backup from{" "}
<strong>
{formatBackupDate(backup.createdAt)}
</strong>
. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
deleteMutation.mutate({
filename: backup.filename,
})
}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</motion.div>
))}
</div>
</CardContent>
)}
</AnimatePresence>
</>
);
}
@@ -0,0 +1,96 @@
import type { SVGProps } from "react";
export function PlexIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 32 32"
aria-hidden="true"
{...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"
/>
</svg>
);
}
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
/>
</svg>
);
}
export function EmbyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513l.512.512l-2.819 2.791L0 12.961l1.83 1.848l3.182 3.209l1.351 1.359l.508-.496c.28-.273.515-.498.524-.498c.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516l-.502-.513c-.276-.282-.5-.52-.496-.53c.003-.009 1.264-1.26 2.802-2.783s2.8-2.776 2.803-2.785c.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497s-1.27-1.267-2.793-2.805A450 450 0 0 0 11.041 0M9.223 7.367c.091.038 7.951 4.608 7.957 4.627c.003.013-1.781 1.056-3.965 2.32a1000 1000 0 0 1-3.996 2.307c-.019.006-.026-1.266-.026-4.629c0-3.7.007-4.634.03-4.625"
/>
</svg>
);
}
export function SonarrIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="m7.338 16.322l.165.159l-2.491 2.495l.13.129l2.493-2.498l.164.159l1.531-1.59l-.461-.444zm.106-8.651l-.161.161L8.855 9.4l.452-.453l-1.572-1.568l-.162.162L5 4.976q-.064.065-.127.132ZM5 4.976l-.128.131c.043-.043.083-.088.128-.131m0-.001l-.129.13l.128-.131ZM16.631 16.24l-1.648-1.64l-.451.453l1.647 1.64l.161-.162l2.533 2.621l.007-.006l.053-.052q.035-.035.067-.073L16.469 16.4ZM19 19.025q-.032.038-.067.073zm-.127.127l.007-.006zm-2.397-11.69l.062.065zl-.163-.162l-1.549 1.575l.455.449l1.549-1.572l-.163-.16l2.544-2.476q-.062-.067-.126-.132Zm2.672-2.346l-.127-.132q.065.065.127.132m.024-.023l-.128-.131l-.022.021l.127.132zm-7.156 4.139a2.66 2.66 0 0 0-1.941.8a2.62 2.62 0 0 0-.795 1.745v.384a3 3 0 0 0 .037.325a2.6 2.6 0 0 0 .763 1.434a2.4 2.4 0 0 0 .342.292a2.76 2.76 0 0 0 3.2 0a2.4 2.4 0 0 0 .279-.233l.059-.059a2.76 2.76 0 0 0 0-3.888a2.65 2.65 0 0 0-1.944-.8m6.917 9.862l-.053.052q.029-.025.053-.052M5.823 4.238l-.008.007Zm-.822.736l-.002.002zm.307 14.333l-.02-.018ZM7.505 12.1a5.64 5.64 0 0 0-1.426-4.257c-.806-.806-1.92-1.916-1.923-1.919a9.3 9.3 0 0 0-2.024 5.35a.13.13 0 0 0-.018.064Q2.1 11.653 2.1 12c0 .219 0 .439.014.658a10 10 0 0 0 .132 1.169a9.3 9.3 0 0 0 2.038 4.4c.007-.007.9-.9 1.75-1.754A5.63 5.63 0 0 0 7.505 12.1m4.527 4.587c-1.806 0-3.036.167-4.358 1.49a432 432 0 0 0-1.694 1.7q.125.098.255.189a9.43 9.43 0 0 0 5.774 1.846a9.5 9.5 0 0 0 5.784-1.846c.1-.068.189-.139.282-.211l-1.6-1.6c-1.428-1.431-2.56-1.568-4.443-1.568m-6.113 3.142L5.9 19.81Zm-.31-.252l-.023-.021Zm6.423-11.986a5.86 5.86 0 0 0 4.441-1.562c.753-.753 1.744-1.74 1.762-1.758a9.52 9.52 0 0 0-6.226-2.18a9.56 9.56 0 0 0-6.186 2.147L7.683 6.1a5.8 5.8 0 0 0 4.349 1.491m6.99-2.607v-.001l-.009-.009l-.002-.002l.002.002Zm-1.183 3.037c-1.2 1.2-1.3 2.238-1.3 4.075a5.7 5.7 0 0 0 1.48 4.358c.879.879 1.712 1.708 1.734 1.73A9.55 9.55 0 0 0 21.9 12a9.6 9.6 0 0 0-2.429-6.531q.217.25.414.5zm1.525 10.616l-.022.023zM19.233 5.2l-.084-.089z"
/>
</svg>
);
}
export function RadarrIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="m8.06 16.01l7.199-4.113l-7.052-3.966Zm-1.028 3.82a2.96 2.96 0 0 1-2.5.294A3.37 3.37 0 0 0 8.648 21.3l10.136-5.876a1.73 1.73 0 0 0 .294-2.645zM19.225 9.106L8.8 3.083C6.738 1.614 3.359 2.5 3.359 6.168l.147 11.605c0 1.175.882 1.763 2.057 1.616L5.416 5.433c0-1.322.735-1.469 1.616-.881l11.752 6.61a2.9 2.9 0 0 1 1.47 2.2a3.307 3.307 0 0 0-1.029-4.256"
/>
</svg>
);
}
@@ -0,0 +1,330 @@
import {
IconBook2,
IconCheck,
IconChevronDown,
IconCopy,
IconExternalLink,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
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 {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { orpc } from "@/lib/orpc/client";
// ─── Types ──────────────────────────────────────────────────────────
export interface IntegrationConnection {
id: string;
provider: string;
type: "webhook" | "list";
token: string;
enabled: boolean;
lastEventAt: string | null;
recentEvents: {
id: string;
eventType: string | null;
mediaType: string | null;
mediaTitle: string | null;
status: "success" | "ignored" | "error";
receivedAt: string;
}[];
}
export interface IntegrationConfig {
provider: string;
label: string;
icon: ComponentType<{ className?: string }>;
/** Build the URL from the connection token. */
buildUrl: (token: string) => string;
/** Label shown above the URL input. */
urlLabel: string;
/** One-line status shown below the title when connected. */
connectedStatus: (lastEventAt: string | null) => string;
/** Optional alert banner shown at the top of the expanded card. */
alert?: ReactNode;
/** Setup instruction steps (rendered inside an <ol>). */
setupSteps: ReactNode;
/** Optional docs link shown after setup steps. */
docsUrl?: string;
}
// ─── Component ──────────────────────────────────────────────────────
export function IntegrationCard({
config,
connection,
setConnections,
}: {
config: IntegrationConfig;
connection: IntegrationConnection | null;
setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>;
}) {
const { provider, label } = config;
const providerInput = provider as
| "plex"
| "jellyfin"
| "emby"
| "sonarr"
| "radarr";
const connectMutation = useMutation(
orpc.integrations.create.mutationOptions({
onSuccess: (result) => {
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
toast.success(`${label} connected`);
},
onError: () => toast.error(`Failed to connect ${label}`),
}),
);
const deleteMutation = useMutation(
orpc.integrations.delete.mutationOptions({
onMutate: () => {
let previous: IntegrationConnection[] = [];
setConnections((prev) => {
previous = prev;
return prev.filter((c) => c.provider !== provider);
});
return { previous };
},
onSuccess: () => toast.success(`${label} disconnected`),
onError: (_, __, ctx) => {
if (ctx?.previous) setConnections(ctx.previous);
toast.error(`Failed to disconnect ${label}`);
},
}),
);
const regenerateTokenMutation = useMutation(
orpc.integrations.regenerateToken.mutationOptions({
onSuccess: (result) => {
setConnections((prev) =>
prev.map((c) =>
c.provider === provider ? { ...c, token: result.token } : c,
),
);
toast.success(`${label} URL regenerated`);
},
onError: () => toast.error(`Failed to regenerate ${label} URL`),
}),
);
const [copied, setCopied] = useState(false);
const [cardOpen, setCardOpen] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const Icon = config.icon;
const connecting = connectMutation.isPending;
const url =
connection && typeof window !== "undefined"
? config.buildUrl(connection.token)
: null;
async function handleCopy() {
if (!url) return;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<Collapsible open={cardOpen} onOpenChange={setCardOpen}>
<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>
<div className="text-left">
<CardTitle>{config.label}</CardTitle>
<CardDescription>
{connection
? config.connectedStatus(connection.lastEventAt)
: "Not configured"}
</CardDescription>
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground 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">
{config.alert}
{!connection ? (
<Button
onClick={() =>
connectMutation.mutate({ provider: providerInput })
}
disabled={connecting}
size="lg"
className="w-full"
>
{connecting ? "Connecting\u2026" : `Connect ${config.label}`}
</Button>
) : (
<AnimatePresence>
{url && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
<div>
<Label
htmlFor={`${config.provider}-url`}
className="mb-1 text-muted-foreground"
>
{config.urlLabel}
</Label>
<InputGroup>
<InputGroupInput
id={`${config.provider}-url`}
readOnly
value={url}
className="font-mono text-[10px] text-muted-foreground"
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
onClick={handleCopy}
/>
}
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
regenerateTokenMutation.mutate({
provider: providerInput,
})
}
>
<IconRefresh />
Regenerate URL
</Button>
<Button
variant="destructive"
size="sm"
onClick={() =>
deleteMutation.mutate({ provider: providerInput })
}
>
<IconTrash />
Disconnect
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)}
<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">
<IconChevronDown
aria-hidden={true}
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
/>
Setup instructions
</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>
{config.docsUrl && (
<p className="mt-2 -ml-0.5">
<IconBook2
aria-hidden={true}
className="mr-1 inline-block size-3 translate-y-[-1px]"
/>
Need more help?{" "}
<a
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"
>
Open docs{" "}
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</p>
)}
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
// ─── Helpers for config authoring ───────────────────────────────────
/** Status line for webhook integrations (shows last event time). */
export function webhookStatus(lastEventAt: string | null): string {
return lastEventAt
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
: "Ready \u2014 nothing received yet";
}
/** Status line for list integrations (shows last event time). */
export function listStatus(lastEventAt: string | null): string {
return lastEventAt
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
: "Ready \u2014 not polled yet";
}
@@ -0,0 +1,247 @@
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
EmbyIcon,
JellyfinIcon,
PlexIcon,
RadarrIcon,
SonarrIcon,
} from "./icons";
import type { IntegrationConfig } from "./integration-card";
import { listStatus, webhookStatus } from "./integration-card";
function origin() {
return typeof window !== "undefined" ? window.location.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">
<IconInfoCircle aria-hidden={true} className="inline-block size-3.5" />
<AlertDescription className="text-foreground/80">
{children}
</AlertDescription>
</Alert>
);
}
export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
// ─── Webhook integrations ───────────────────────────────────────
{
provider: "plex",
label: "Plex",
icon: PlexIcon,
buildUrl: (token) => `${origin()}/api/webhooks/${token}`,
urlLabel: "Webhook URL",
connectedStatus: webhookStatus,
alert: (
<RequirementAlert>
Requires an active{" "}
<a
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"
>
Plex Pass
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
subscription.
</RequirementAlert>
),
setupSteps: (
<>
<li>
Open Plex, go to{" "}
<a
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"
>
Settings &gt; Webhooks
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</li>
<li>
Click <span className="font-medium text-foreground">Add Webhook</span>{" "}
and paste the URL above
</li>
<li>
Sofa will automatically log movies and episodes when you finish
watching them
</li>
</>
),
docsUrl: "https://support.plex.tv/hc/en-us/articles/115002267687-Webhooks/",
},
{
provider: "jellyfin",
label: "Jellyfin",
icon: JellyfinIcon,
buildUrl: (token) => `${origin()}/api/webhooks/${token}`,
urlLabel: "Webhook URL",
connectedStatus: webhookStatus,
setupSteps: (
<>
<li>
Install the{" "}
<a
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"
>
Webhook plugin
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
from Jellyfin&apos;s plugin catalog
</li>
<li>
Go to{" "}
<span className="font-medium text-foreground">
Dashboard &gt; Plugins &gt; Webhook
</span>
</li>
<li>
Add a{" "}
<span className="font-medium text-foreground">
Generic Destination
</span>{" "}
and paste the URL above
</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">Playback Stop</span>{" "}
notification type
</li>
<li>
Sofa will automatically log movies and episodes when you finish
watching them
</li>
</>
),
docsUrl: "https://jellyfin.org/docs/general/server/notifications/",
},
{
provider: "emby",
label: "Emby",
icon: EmbyIcon,
buildUrl: (token) => `${origin()}/api/webhooks/${token}`,
urlLabel: "Webhook URL",
connectedStatus: webhookStatus,
alert: (
<RequirementAlert>
Requires{" "}
<span className="font-medium text-foreground">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"
>
Emby Premiere
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
license.
</RequirementAlert>
),
setupSteps: (
<>
<li>
Open Emby, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Webhooks
</span>
</li>
<li>Add a new webhook and paste the URL above</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">Playback</span> event
category
</li>
<li>
Sofa will automatically log movies and episodes when you finish
watching them
</li>
</>
),
docsUrl: "https://emby.media/support/articles/Webhooks.html",
},
// ─── List integrations ──────────────────────────────────────────
{
provider: "sonarr",
label: "Sonarr",
icon: SonarrIcon,
buildUrl: (token) => `${origin()}/api/lists/${token}`,
urlLabel: "Sonarr List URL",
connectedStatus: listStatus,
setupSteps: (
<>
<li>
Open Sonarr, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Import Lists
</span>
</li>
<li>
Click <span className="font-medium text-foreground">+</span> and
select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
</li>
<li>Paste the Sonarr URL above into the List URL field</li>
<li>Set your preferred quality profile and root folder</li>
<li>
Titles on your Sofa watchlist will be automatically added for download
when Sonarr polls this list (every 6 hours by default)
</li>
</>
),
docsUrl: "https://wiki.servarr.com/sonarr/settings#import-lists",
},
{
provider: "radarr",
label: "Radarr",
icon: RadarrIcon,
buildUrl: (token) => `${origin()}/api/lists/${token}`,
urlLabel: "Radarr List URL",
connectedStatus: listStatus,
setupSteps: (
<>
<li>
Open Radarr, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Import Lists
</span>
</li>
<li>
Click <span className="font-medium text-foreground">+</span> and
select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
</li>
<li>Paste the Radarr URL above into the List URL field</li>
<li>Set your preferred quality profile and root folder</li>
<li>
Titles on your Sofa watchlist will be automatically added for download
when Radarr polls this list (every 12 hours by default)
</li>
</>
),
docsUrl: "https://wiki.servarr.com/radarr/settings#import-lists",
},
];
@@ -0,0 +1,67 @@
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 { INTEGRATION_CONFIGS } from "./integration-configs";
export function IntegrationsSection() {
const { data, isPending } = useQuery(orpc.integrations.list.queryOptions());
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[]),
) {
setLocalConnections((prev) => {
const current = prev ?? data?.integrations ?? [];
return typeof updater === "function" ? updater(current) : updater;
});
}
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">
Integrations
</h2>
</div>
{isPending ? (
<div className="space-y-3">
{INTEGRATION_CONFIGS.map((c) => (
<Skeleton key={c.provider} className="h-20 w-full rounded-xl" />
))}
</div>
) : (
<div className="space-y-3">
{INTEGRATION_CONFIGS.map((config) => (
<IntegrationCard
key={config.provider}
config={config}
connection={
connections.find(
(c: IntegrationConnection) => c.provider === config.provider,
) ?? null
}
setConnections={handleSetConnections}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,68 @@
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";
import { orpc } from "@/lib/orpc/client";
export function RegistrationSection() {
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(),
);
if (isLoading) {
return (
<CardContent>
<Skeleton className="h-12 w-full" />
</CardContent>
);
}
function handleToggle(checked: boolean) {
startTransition(async () => {
setOptimisticOpen(checked);
try {
await toggleMutation.mutateAsync({ open: checked });
setRegistrationOpen(checked);
toast.success(checked ? "Registration opened" : "Registration closed");
} catch {
toast.error("Failed to update registration setting");
}
});
}
return (
<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>
<div>
<CardTitle>Open registration</CardTitle>
<CardDescription>
Allow new users to create accounts
</CardDescription>
</div>
</div>
<Switch
checked={optimisticOpen}
onCheckedChange={handleToggle}
disabled={isPending}
aria-label="Toggle open registration"
/>
</div>
</CardContent>
);
}
@@ -0,0 +1,50 @@
import { IconSettings } from "@tabler/icons-react";
import { motion } from "motion/react";
import { Children, type ReactNode } from "react";
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring" as const, stiffness: 200, damping: 24 },
},
};
export function SettingsShell({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) {
return (
<motion.div
className="mx-auto max-w-2xl space-y-8"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.15 } },
}}
>
<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">
Settings
</h1>
</div>
<p className="mt-1 text-muted-foreground text-sm">
Manage your account and preferences
</p>
</motion.div>
{Children.map(children, (child) => (
<motion.div variants={sectionVariants}>{child}</motion.div>
))}
{footer && <motion.div variants={sectionVariants}>{footer}</motion.div>}
</motion.div>
);
}
@@ -0,0 +1,640 @@
import type { SystemHealthData } from "@sofa/api/schemas";
import {
IconActivity,
IconAlertTriangle,
IconCalendarCheck,
IconCheck,
IconDatabase,
IconPlayerPlay,
IconRefresh,
} 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 { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { orpc } from "@/lib/orpc/client";
const JOB_LABELS: Record<string, string> = {
nightlyRefreshLibrary: "Library refresh",
refreshAvailability: "Availability",
refreshRecommendations: "Recommendations",
refreshTvChildren: "TV episodes",
cacheImages: "Image cache",
scheduledBackup: "Backup",
updateCheck: "Update check",
};
/** Convert a cron pattern to a short human-readable string */
function cronToHuman(pattern: string): string {
const parts = pattern.split(" ");
if (parts.length !== 5) return pattern;
const [min, hour, _dom, _mon, dow] = parts;
// Every N hours: "0 */6 * * *"
if (hour.startsWith("*/")) {
const n = Number.parseInt(hour.slice(2), 10);
return `Every ${n}h`;
}
// Twice daily: "0 1,13 * * *"
if (hour.includes(",") && !hour.includes("/") && !hour.includes("-")) {
const hours = hour.split(",");
if (hours.length === 2) {
return `Daily at ${hours.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`).join(", ")}`;
}
}
// Daily at specific time: "0 3 * * *"
if (/^\d+$/.test(hour) && /^\d+$/.test(min) && dow === "*") {
return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
}
// Weekly
if (/^\d+$/.test(dow)) {
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return `Weekly on ${days[Number(dow)] ?? dow}`;
}
return pattern;
}
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`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 60000)}m`;
}
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">
<CardContent>
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-8 w-8 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-48" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
/** Inline component that live-updates a relative timestamp */
function LiveTimeAgo({
date,
fallback = "",
}: {
date: string | Date | null | undefined;
fallback?: string;
}) {
const text = useTimeAgo(date, { fallback });
return <>{text}</>;
}
/** Hydrates system health state and renders the 3 cards */
export function SystemHealthCards() {
const queryClient = useQueryClient();
const {
data: statusData,
isPending,
isFetching,
} = useQuery(orpc.systemStatus.queryOptions());
const data = statusData?.health ?? null;
const isRefreshing = isFetching;
const refresh = () =>
queryClient.invalidateQueries({ queryKey: orpc.systemStatus.key() });
if (isPending || !data) return <SkeletonCards />;
return (
<div className="space-y-3">
<SystemStatusCard
checkedAt={data.checkedAt}
database={data.database}
tmdb={data.tmdb}
environment={data.environment}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
<BackgroundJobsCard
jobs={data.jobs}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
<StorageCard
imageCache={data.imageCache}
backups={data.backups}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
</div>
);
}
function RefreshButton({
isRefreshing,
onRefresh,
}: {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label="Refresh system health"
onClick={onRefresh}
disabled={isRefreshing}
className="text-muted-foreground"
/>
}
>
{isRefreshing ? <Spinner /> : <IconRefresh />}
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
);
}
function SystemStatusCard({
checkedAt,
database,
tmdb,
environment,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "checkedAt" | "database" | "tmdb" | "environment"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Card className="border-l-2 border-l-primary/30">
<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>
<div>
<CardTitle>Health status</CardTitle>
<CardDescription suppressHydrationWarning>
Checked <LiveTimeAgo date={checkedAt} />
</CardDescription>
</div>
</div>
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
</CardContent>
{/* 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">
Database
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{formatBytes(database.dbSizeBytes)}
{database.walSizeBytes > 0 &&
` + ${formatBytes(database.walSizeBytes)} WAL`}
</span>
</div>
</CardContent>
{/* 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">
TMDB API
</span>
{!tmdb.tokenConfigured ? (
<>
<StatusDot status="error" />
<span className="text-muted-foreground/50 text-xs">
Not configured
</span>
</>
) : tmdb.connected && tmdb.tokenValid ? (
<>
<StatusDot status="ok" />
<span className="text-muted-foreground text-xs">Connected</span>
<span className="font-mono text-[11px] text-muted-foreground/80">
{tmdb.responseTimeMs}ms
</span>
</>
) : tmdb.connected && !tmdb.tokenValid ? (
<>
<StatusDot status="error" />
<span className="text-destructive text-xs">Invalid token</span>
</>
) : (
<>
<StatusDot status="error" />
<span className="text-destructive text-xs">Unreachable</span>
{tmdb.error && (
<span className="text-[11px] text-muted-foreground/50">
{tmdb.error}
</span>
)}
</>
)}
</div>
</CardContent>
{/* 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">
Environment
{environment.dataDirWritable ? (
<IconCheck aria-hidden={true} className="size-3 text-green-500" />
) : (
<IconAlertTriangle
aria-hidden={true}
className="size-3 text-destructive"
/>
)}
</span>
<div className="space-y-1">
{environment.envVars
.filter((env) => env.value !== null)
.map((env) => (
<div
key={env.name}
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>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
function BackgroundJobsCard({
jobs,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "jobs"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
const triggerJobMutation = useMutation(
orpc.admin.triggerJob.mutationOptions({
onSuccess: (_, { name }) => {
toast.success(`${JOB_LABELS[name] ?? name} triggered`);
setTimeout(onRefresh, 1500);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : "Failed to trigger job",
);
},
}),
);
const triggeringJob = triggerJobMutation.isPending
? (triggerJobMutation.variables?.name ?? null)
: null;
const sortedJobs = [...jobs].sort((a, b) => {
if (a.disabled !== b.disabled) return a.disabled ? 1 : -1;
if (!a.nextRunAt && !b.nextRunAt) return 0;
if (!a.nextRunAt) return 1;
if (!b.nextRunAt) return -1;
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;
return (
<Card className="border-l-2 border-l-primary/30">
<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>
<div>
<CardTitle>Background jobs</CardTitle>
<CardDescription>
{healthyCount} of {activeJobs.length} jobs healthy
</CardDescription>
</div>
</div>
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
</CardContent>
<CardContent className="border-border/30 border-t px-0 pt-0 pb-0">
<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">
Job
</TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Schedule
</TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Last run
</TableHead>
<TableHead className="h-8 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Next run
</TableHead>
<TableHead className="h-8 pr-5 text-right font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedJobs.map((job) => {
const isTriggering = triggeringJob === job.jobName;
const isRunning = job.isCurrentlyRunning || isTriggering;
return (
<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">
{job.disabled ? (
<StatusDot status="inactive" label="Disabled" />
) : isRunning ? (
<Spinner className="size-2.5" />
) : job.lastStatus === null ? (
<StatusDot status="warn" label="Never run" />
) : job.lastStatus === "success" ? (
<StatusDot status="ok" label="Last run succeeded" />
) : (
<StatusDot
status="error"
label={job.lastError ?? "Last run failed"}
/>
)}
<span className="text-muted-foreground text-xs">
{JOB_LABELS[job.jobName] ?? job.jobName}
</span>
</div>
</TableCell>
{/* Schedule */}
<TableCell>
{job.cronPattern ? (
<Tooltip>
<TooltipTrigger className="cursor-default">
<span className="text-muted-foreground/80 text-xs">
{cronToHuman(job.cronPattern)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="font-mono">{job.cronPattern}</span>
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/50 text-xs">
</span>
)}
</TableCell>
{/* Last run */}
<TableCell>
{job.lastRunAt ? (
<Tooltip>
<TooltipTrigger className="cursor-default">
<div className="flex items-baseline gap-1.5">
<span
className="text-muted-foreground/80 text-xs"
suppressHydrationWarning
>
<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>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{new Date(job.lastRunAt).toLocaleString()}
{job.lastError && (
<div className="mt-1 text-destructive">
{job.lastError}
</div>
)}
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/50 text-xs">
Never
</span>
)}
</TableCell>
{/* Next run */}
<TableCell>
{job.nextRunAt ? (
<Tooltip>
<TooltipTrigger className="cursor-default">
<span
className="text-muted-foreground/80 text-xs"
suppressHydrationWarning
>
<LiveTimeAgo date={job.nextRunAt} />
</span>
</TooltipTrigger>
<TooltipContent>
{new Date(job.nextRunAt).toLocaleString()}
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/50 text-xs">
</span>
)}
</TableCell>
{/* Trigger button */}
<TableCell className="pr-5 text-right">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label="Trigger job"
className="size-6"
disabled={isRunning || job.disabled}
onClick={() =>
triggerJobMutation.mutate({ name: job.jobName })
}
/>
}
>
{isRunning ? (
<Spinner className="size-3" />
) : (
<IconPlayerPlay
aria-hidden={true}
className="size-3 text-muted-foreground/70"
/>
)}
</TooltipTrigger>
<TooltipContent>Run now</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
function StorageCard({
imageCache,
backups,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "imageCache" | "backups"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Card className="border-l-2 border-l-primary/30">
<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>
<div>
<CardTitle>Storage</CardTitle>
<CardDescription>
Image cache and backup disk usage
</CardDescription>
</div>
</div>
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
</CardContent>
{/* 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">
Image cache
</span>
{imageCache.enabled ? (
<span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(imageCache.totalSizeBytes)}
</span>
) : null}
</div>
{imageCache.enabled ? (
<>
<p className="mt-1 text-muted-foreground text-xs">
{imageCache.imageCount.toLocaleString()} cached images
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground/50 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">
<StatusDot status="inactive" />
Disabled
</p>
)}
</CardContent>
{/* 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">
Backups
</span>
{backups.backupCount > 0 && (
<span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(backups.totalSizeBytes)}
</span>
)}
</div>
{backups.backupCount > 0 ? (
<p
className="mt-1 text-muted-foreground text-xs"
suppressHydrationWarning
>
{backups.backupCount} backups · last{" "}
<LiveTimeAgo date={backups.lastBackupAt} fallback="unknown" />
</p>
) : (
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
<StatusDot status="inactive" />
No backups yet
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,72 @@
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";
import { orpc } from "@/lib/orpc/client";
export function UpdateCheckSection() {
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 [isPending, startTransition] = useTransition();
const toggleMutation = useMutation(
orpc.admin.toggleUpdateCheck.mutationOptions(),
);
if (isLoading) {
return (
<CardContent>
<Skeleton className="h-12 w-full" />
</CardContent>
);
}
function handleToggle(checked: boolean) {
startTransition(async () => {
setOptimisticEnabled(checked);
try {
await toggleMutation.mutateAsync({ enabled: checked });
setLocalEnabled(checked);
toast.success(
checked ? "Update checks enabled" : "Update checks disabled",
);
} catch {
toast.error("Failed to update setting");
}
});
}
return (
<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>
<div>
<CardTitle>Automatic update checks</CardTitle>
<CardDescription>
Periodically check GitHub for new Sofa releases
</CardDescription>
</div>
</div>
<Switch
checked={optimisticEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
aria-label="Toggle automatic update checks"
/>
</div>
</CardContent>
);
}
@@ -0,0 +1,34 @@
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
function handleCopy() {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="text-[11px] text-muted-foreground"
>
{copied ? (
<>
<IconCheck aria-hidden={true} className="size-3 text-green-400" />
Copied
</>
) : (
<>
<IconCopy aria-hidden={true} className="size-3" />
Copy
</>
)}
</Button>
);
}
@@ -0,0 +1,29 @@
import { IconRefresh } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export function RefreshButton() {
const [isRefreshing, setIsRefreshing] = useState(false);
function handleRefresh() {
setIsRefreshing(true);
window.location.reload();
}
return (
<Button
size="lg"
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Spinner />
) : (
<IconRefresh aria-hidden={true} className="size-3.5" />
)}
{isRefreshing ? "Checking…" : "Check configuration"}
</Button>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function SofaLogo({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className={className ?? "size-6"}
role="img"
aria-label="Sofa"
>
<path
fill="currentColor"
d="M7 12v1h10v-1a3 3 0 0 1 2.993-3a4.6 4.6 0 0 0-.07-.78a4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12"
/>
<path
fill="currentColor"
d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092"
/>
</svg>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { motion, useReducedMotion } from "motion/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const colors = {
ok: { bg: "bg-green-500", shadow: "0 0 0 0px rgba(74,222,128,0.5)" },
error: { bg: "bg-destructive", shadow: "0 0 0 0px rgba(248,113,113,0.5)" },
warn: { bg: "bg-amber-500", shadow: "0 0 0 0px rgba(251,191,36,0.5)" },
inactive: { bg: "bg-muted-foreground/30", shadow: "" },
};
const pulseColors = {
ok: "0 0 0 4px rgba(74,222,128,0)",
error: "0 0 0 4px rgba(248,113,113,0)",
warn: "0 0 0 4px rgba(251,191,36,0)",
};
const defaultLabels: Record<string, string> = {
ok: "Healthy",
error: "Error",
warn: "Warning",
inactive: "Inactive",
};
/** Small colored status dot with a pulsing ring for active states. */
export function StatusDot({
status,
label,
className,
}: {
status: "ok" | "error" | "warn" | "inactive";
/** Tooltip text. Defaults to a label derived from the status. */
label?: string;
className?: string;
}) {
const { bg, shadow } = colors[status];
const pulse = status !== "inactive";
const tooltipText = label ?? defaultLabels[status];
const prefersReducedMotion = useReducedMotion();
const dotEl = pulse ? (
<motion.span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
bg,
className,
)}
animate={
prefersReducedMotion
? {}
: {
boxShadow: [
shadow,
pulseColors[status as keyof typeof pulseColors],
],
}
}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: "easeOut",
repeatDelay: 0.3,
}}
/>
) : (
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
bg,
className,
)}
/>
);
return (
<Tooltip>
<TooltipTrigger className="cursor-default">{dotEl}</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
}
+352
View File
@@ -0,0 +1,352 @@
import {
IconBookmarkFilled,
IconCircleCheckFilled,
IconDeviceTv,
IconLoader,
IconMovie,
IconPlayerPlayFilled,
IconPlus,
IconStarFilled,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { orpc } from "@/lib/orpc/client";
export function TitleCardSkeleton() {
return (
<div className="overflow-hidden rounded-xl bg-card 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" />
<Skeleton className="mt-1.5 h-3 w-1/2" />
</div>
</div>
);
}
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TiltStyles {
imageStyle: MotionStyle;
glareBackground: MotionValue<string>;
glareOpacity: MotionValue<number>;
}
interface CardInnerProps {
title: string;
type: string;
posterPath: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
tiltStyles?: TiltStyles;
}
export interface TitleCardProps extends CardInnerProps {
id?: string;
tmdbId: number;
}
const statusConfig = {
watchlist: {
icon: IconBookmarkFilled,
label: "On Watchlist",
badgeClass: "bg-status-watching/90 text-white",
},
in_progress: {
icon: IconPlayerPlayFilled,
label: "Watching",
badgeClass: "bg-status-watching/90 text-white",
},
completed: {
icon: IconCircleCheckFilled,
label: "Completed",
badgeClass: "bg-status-completed/90 text-white",
},
} as const;
function QuickAddButton({
tmdbId,
type,
userStatus,
}: {
tmdbId: number;
type: "movie" | "tv";
userStatus?: TitleStatus | null;
}) {
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
userStatus ?? null,
);
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
useEffect(() => {
if (userStatus) {
setAddedStatus(userStatus);
}
}, [userStatus]);
const quickAddMutation = useMutation(
orpc.watchlist.quickAdd.mutationOptions({
onSuccess: () => setAddedStatus("watchlist"),
}),
);
const isAdded = addedStatus != null;
const config = addedStatus ? statusConfig[addedStatus] : null;
function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ tmdbId, type });
}
if (isAdded && config) {
const StatusIcon = config.icon;
return (
<Tooltip>
<TooltipTrigger
className="absolute top-2 right-2 z-10 flex size-8 cursor-default items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm"
render={<div />}
>
<StatusIcon className="size-4" />
</TooltipTrigger>
<TooltipContent side="bottom">{config.label}</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger
onClick={handleClick}
className="absolute top-2 right-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
render={<button type="button" />}
>
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
{quickAddMutation.isPending && (
<IconLoader className="size-4 animate-spin" />
)}
</TooltipTrigger>
<TooltipContent side="bottom">Add to Watchlist</TooltipContent>
</Tooltip>
);
}
function ProgressBar({ watched, total }: { watched: number; total: number }) {
const pct = total > 0 ? (watched / total) * 100 : 0;
return (
<Tooltip>
<TooltipTrigger
className="absolute right-0 bottom-0 left-0 z-10 h-1 cursor-default bg-white/10"
render={<div />}
>
<div
className="h-full bg-status-watching transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top">
{watched}/{total} episodes
</TooltipContent>
</Tooltip>
);
}
function CardInner({
title,
type,
posterPath,
releaseDate,
voteAverage,
userStatus,
episodeProgress,
tiltStyles,
}: CardInnerProps) {
const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
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}`}
>
<div className="aspect-[2/3] overflow-hidden bg-card">
{posterPath ? (
<motion.div style={tiltStyles?.imageStyle}>
<img
src={posterPath}
alt={title}
width={300}
height={450}
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</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="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="relative px-3 text-center">
<p className="font-display text-foreground/70 text-sm leading-snug tracking-tight">
{title}
</p>
</div>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
{tiltStyles && (
<motion.div
className="pointer-events-none absolute inset-0 z-[5] rounded-xl"
style={{
background: tiltStyles.glareBackground,
opacity: tiltStyles.glareOpacity,
}}
/>
)}
</div>
<div className="px-3 pt-2.5 pb-3">
<div className="flex items-center gap-1.5">
{userStatus && (
<Tooltip>
<TooltipTrigger
className="shrink-0 cursor-default"
render={<div className="flex items-center" />}
>
<span className="relative flex size-2">
<span
className={`absolute inline-flex h-full w-full rounded-full opacity-40 ${userStatus === "completed" ? "bg-status-completed" : "bg-status-watching"}`}
/>
<span
className={`relative inline-flex size-2 rounded-full ${userStatus === "completed" ? "bg-status-completed" : "bg-status-watching"}`}
/>
</span>
</TooltipTrigger>
<TooltipContent>{statusConfig[userStatus].label}</TooltipContent>
</Tooltip>
)}
<p className="line-clamp-1 font-medium text-sm leading-snug">
{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"
/>
{year && <span>{year}</span>}
{voteAverage != null && voteAverage > 0 && (
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
<IconStarFilled aria-hidden={true} className="size-[11px]" />
{voteAverage.toFixed(1)}
</span>
)}
</div>
</div>
{episodeProgress && episodeProgress.watched > 0 && (
<ProgressBar
watched={episodeProgress.watched}
total={episodeProgress.total}
/>
)}
</div>
);
}
export function TitleCard({
id,
tmdbId,
type,
title,
posterPath,
releaseDate,
voteAverage,
userStatus,
episodeProgress,
}: TitleCardProps) {
const tilt = useTiltEffect();
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id: resolvedId }) => {
if (resolvedId)
void navigate({ to: "/titles/$id", params: { id: resolvedId } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
);
return (
<div className="group relative">
<QuickAddButton
tmdbId={tmdbId}
type={type as "movie" | "tv"}
userStatus={userStatus}
/>
{id ? (
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
) : (
<button
type="button"
disabled={resolveMutation.isPending}
className={`w-full text-left ${resolveMutation.isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
onClick={() => {
progress.start();
resolveMutation.mutate({ tmdbId, type: type as "movie" | "tv" });
}}
>
{cardContent}
</button>
)}
</div>
);
}
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
import { SeasonsSkeleton, TitleSeasons } from "./title-seasons";
export function AsyncTitleSeasons({
titleId,
tmdbId,
}: {
titleId: string;
tmdbId: number;
}) {
const { data, isPending } = useQuery(
orpc.titles.hydrateSeasons.queryOptions({
input: { id: titleId, tmdbId },
}),
);
if (isPending) return <SeasonsSkeleton />;
if (!data?.seasons || data.seasons.length === 0) return null;
return <TitleSeasons seasons={data.seasons} />;
}
@@ -0,0 +1,79 @@
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";
interface CastCarouselProps {
actors: CastMember[];
titleType: "movie" | "tv";
}
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" />
<h2 className="font-display text-xl tracking-tight">Cast</h2>
</div>
{actors.length > 0 && (
<ScrollArea scrollFade hideScrollbar className="-mx-4 sm:-mx-0">
<div className="flex gap-1 px-4 py-2 sm:px-0">
{actors.map((member, i) => (
<div key={member.id} className="w-[100px] shrink-0 sm:w-[120px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<Link
to="/people/$id"
params={{ id: member.personId }}
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">
{member.profilePath ? (
<img
src={member.profilePath}
alt={member.name}
width={96}
height={96}
loading="lazy"
decoding="async"
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">
<IconUser
aria-hidden={true}
className="size-8 text-muted-foreground/50"
/>
</div>
)}
</div>
<div className="w-full text-center">
<p className="truncate font-medium text-xs">
{member.name}
</p>
{member.character && (
<p className="truncate text-[10px] text-muted-foreground">
{member.character}
</p>
)}
{titleType === "tv" && member.episodeCount && (
<p className="text-[10px] text-muted-foreground/70">
{member.episodeCount} ep
{member.episodeCount !== 1 ? "s" : ""}
</p>
)}
</div>
</Link>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</section>
);
}
@@ -0,0 +1,39 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function GenreCollapse({ genres }: { genres: string[] }) {
if (genres.length === 0) return null;
if (genres.length === 1) return <span>{genres[0]}</span>;
const remaining = genres.slice(1);
return (
<span className="inline-flex items-center gap-1">
<span>{genres[0]}</span>
<Popover>
<PopoverTrigger
openOnHover
delay={0}
closeDelay={300}
className="cursor-default text-muted-foreground/70 transition-colors hover:text-muted-foreground"
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">
{remaining.map((genre) => (
<span
key={genre}
className="px-2 py-1 text-[13px] text-popover-foreground"
>
{genre}
</span>
))}
</PopoverContent>
</Popover>
</span>
);
}
@@ -0,0 +1,59 @@
import { IconStar, IconStarFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useState } from "react";
const springTransition = {
type: "spring" as const,
stiffness: 400,
damping: 15,
};
interface StarRatingProps {
value: number;
onChange: (value: number) => void;
}
export function StarRating({ value, onChange }: StarRatingProps) {
const [hover, setHover] = useState(0);
return (
<div
className="flex items-center gap-0.5"
role="radiogroup"
aria-label="Rating"
onMouseLeave={() => setHover(0)}
>
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= (hover || value);
return (
<motion.button
key={star}
type="button"
role="radio"
aria-checked={star === value}
aria-label={`Rate ${star} star${star !== 1 ? "s" : ""}`}
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
className="p-0.5"
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }}
animate={
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
}
transition={
filled && star === value
? { type: "tween", duration: 0.3, ease: "easeInOut" }
: springTransition
}
>
{filled ? (
<IconStarFilled className="size-4.5 text-primary" />
) : (
<IconStar className="size-4.5 text-muted-foreground/30" />
)}
</motion.button>
);
})}
</div>
);
}
@@ -0,0 +1,88 @@
import {
IconCheck,
IconPlayerPlayFilled,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
const watchingStyle = {
label: "Watching",
icon: IconPlayerPlayFilled,
class: "text-status-watching",
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
borderClass: "ring-status-watching/20",
};
const statusConfig = {
watchlist: watchingStyle,
in_progress: watchingStyle,
completed: {
label: "Completed",
icon: IconCheck,
class: "text-status-completed",
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
borderClass: "ring-status-completed/20",
},
} as const;
interface StatusButtonProps {
currentStatus: string | null;
onChange: (status: string | null) => void;
}
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
const config =
statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
return (
<AnimatePresence mode="wait" initial={false}>
{!config ? (
<motion.button
key="add"
type="button"
onClick={() => onChange("watchlist")}
initial={{ opacity: 0, y: 4 }}
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]"
>
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
Watchlist
</motion.button>
) : (
<motion.button
key="status"
type="button"
onClick={() => onChange(null)}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
title="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`}
>
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
<config.icon
aria-hidden={true}
className="size-3.5 transition-opacity group-hover:opacity-0"
/>
<IconX
aria-hidden={true}
className="size-3.5 text-destructive 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="opacity-0 transition-opacity group-hover:opacity-100">
Remove
</span>
</span>
</motion.button>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,38 @@
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";
import { useTitleActions } from "./use-title-actions";
export function TitleActions() {
const { titleType } = useTitleContext();
const { userStatus, userRating } = useTitleUserInfo();
const { handleStatusChange, handleRating, handleWatchMovie } =
useTitleActions();
return (
<div className="flex flex-wrap items-center gap-3">
<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]"
>
<IconCheck aria-hidden={true} className="size-3.5" />
Mark Watched
</Button>
)}
<Separator
orientation="vertical"
className="mx-0.5 my-auto h-6 bg-border/50"
/>
<StarRating value={userRating ?? 0} onChange={handleRating} />
</div>
);
}
@@ -0,0 +1,179 @@
import type { AvailabilityOffer } from "@sofa/api/schemas";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const MAX_VISIBLE = 4;
const offerLabels: Record<string, string> = {
flatrate: "Stream",
rent: "Rent",
buy: "Buy",
free: "Free",
ads: "With Ads",
};
function ProviderBadge({
name,
logoPath,
watchUrl,
}: {
name: string;
logoPath: string | null;
watchUrl: string | null;
}) {
return (
<Tooltip>
<TooltipTrigger
{...(watchUrl
? {
render: (
// biome-ignore lint/a11y/useAnchorContent: content is provided conditionally below
<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"}`}
>
{logoPath ? (
<img
src={logoPath}
alt={name}
width={40}
height={40}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
) : (
<span className="font-medium text-[8px] text-muted-foreground">
{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">
{watchUrl ? `Watch on ${name}` : name}
</TooltipContent>
</Tooltip>
);
}
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">
{offer.logoPath ? (
<img
src={offer.logoPath}
alt={offer.providerName}
width={28}
height={28}
loading="lazy"
decoding="async"
className="h-7 w-7 object-cover"
/>
) : (
<span className="font-medium text-[7px] text-muted-foreground">
{offer.providerName.slice(0, 2)}
</span>
)}
</div>
);
}
function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
return (
<Popover>
<PopoverTrigger
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"
>
+{offers.length}
</PopoverTrigger>
<PopoverContent className="flex w-auto max-w-64 flex-col gap-0 divide-y divide-border/30 p-0.5">
{offers.map((offer) =>
offer.watchUrl ? (
<a
key={offer.providerId}
href={offer.watchUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-2 py-1.5 hover:bg-muted/50"
>
<OverflowProviderIcon offer={offer} />
<span className="truncate text-popover-foreground text-xs">
{offer.providerName}
</span>
</a>
) : (
<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>
</div>
),
)}
</PopoverContent>
</Popover>
);
}
export function TitleAvailability({
availability,
}: {
availability: AvailabilityOffer[];
}) {
const availByType: Record<string, AvailabilityOffer[]> = {};
for (const offer of availability) {
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
availByType[offer.offerType].push(offer);
}
if (Object.keys(availByType).length === 0) return null;
return (
<div className="space-y-2 pt-1">
<h2 className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">
Where to Watch
</h2>
<div className="flex flex-wrap gap-4">
{Object.entries(availByType).map(([type, offers]) => {
const visible = offers.slice(0, MAX_VISIBLE);
const overflow = offers.slice(MAX_VISIBLE);
return (
<div key={type} className="space-y-1.5">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-wider">
{offerLabels[type] ?? type}
</span>
<div className="flex gap-1.5">
{visible.map((offer) => (
<ProviderBadge
key={offer.providerId}
name={offer.providerName}
logoPath={offer.logoPath}
watchUrl={offer.watchUrl}
/>
))}
{overflow.length > 0 && <OverflowBadge offers={overflow} />}
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import type { CastMember } from "@sofa/api/schemas";
import { CastCarousel } from "./cast-carousel";
interface TitleCastProps {
cast: CastMember[];
titleType: "movie" | "tv";
}
export function TitleCast({ cast, titleType }: TitleCastProps) {
const actors = cast.filter((c) => c.department === "Acting");
if (actors.length === 0) return null;
return <CastCarousel actors={actors} titleType={titleType} />;
}
@@ -0,0 +1,38 @@
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";
interface TitleContextValue {
titleId: string;
titleType: "movie" | "tv";
titleName: string;
seasons: Season[];
setSeasons: (seasons: Season[]) => void;
watchingEp: string | null;
setWatchingEp: (id: string | null) => void;
}
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");
return ctx;
}
export function useTitleUserInfo() {
const { titleId } = useTitleContext();
const { data: session } = useSession();
const { data } = useQuery({
...orpc.titles.userInfo.queryOptions({ input: { id: titleId } }),
enabled: !!session,
});
return {
userStatus: data?.status ?? null,
userRating: data?.rating ?? 0,
episodeWatches: data?.episodeWatches ?? [],
};
}
@@ -0,0 +1,239 @@
import type { ColorPalette, ResolvedTitle } from "@sofa/api/schemas";
import {
IconCalendarEvent,
IconCircleCheck,
IconCircleX,
IconDeviceTv,
IconLoader,
IconMovie,
IconRefresh,
IconStarFilled,
} from "@tabler/icons-react";
import type { ReactNode } from "react";
import { ExpandableText } from "@/components/expandable-text";
import { TmdbLogo } from "@/components/tmdb-logo";
import { GenreCollapse } from "./genre-collapse";
import { TrailerDialog } from "./trailer-dialog";
export function TitleHero({
title,
trailerVideoKey,
actions,
children,
}: {
title: ResolvedTitle;
trailerVideoKey?: string | null;
actions: ReactNode;
children?: ReactNode;
}) {
const dateStr = title.releaseDate ?? title.firstAirDate;
const year = dateStr?.slice(0, 4);
const palette = title.colorPalette;
return (
<>
{/* Backdrop hero */}
{title.backdropPath && (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 overflow-hidden md:h-[28rem]">
<img
src={title.backdropPath}
alt=""
loading="eager"
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" />
{palette?.darkMuted && (
<div
className="absolute inset-0 opacity-40 mix-blend-multiply"
style={{
background: `radial-gradient(ellipse at 25% 85%, ${palette.darkMuted} 0%, transparent 65%)`,
}}
/>
)}
{palette?.vibrant && (
<div
className="absolute inset-0 opacity-[0.08]"
style={{
background: `radial-gradient(ellipse at 50% 70%, ${palette.vibrant} 0%, transparent 55%)`,
}}
/>
)}
<div
className="pointer-events-none absolute inset-0 opacity-[0.03]"
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")`,
}}
/>
{trailerVideoKey && (
<div className="absolute inset-0 z-10 mb-12 flex items-center justify-center">
<TrailerDialog videoKey={trailerVideoKey} variant="backdrop" />
</div>
)}
</div>
)}
{/* Ambient glow orbs */}
<AmbientGlow palette={palette} />
{/* Title header */}
<div
className={`flex flex-col gap-4 md:flex-row md:gap-8 ${title.backdropPath ? "relative z-10 -mt-32" : ""}`}
>
{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"
style={{
boxShadow: palette?.darkVibrant
? `0 25px 60px -12px ${palette.darkVibrant}50, 0 12px 28px -8px rgba(0,0,0,0.5)`
: "0 25px 50px -12px rgba(0,0,0,0.5)",
}}
>
<img
src={title.posterPath}
alt={title.title}
width={220}
height={330}
loading="eager"
decoding="async"
className="aspect-[2/3] w-[140px] object-cover md:w-[220px]"
/>
</div>
</div>
)}
<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">
{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">
{title.type === "movie" ? (
<>
<IconMovie
aria-hidden
className="size-3.5 translate-y-[-0.5px]"
/>
<span className="hidden md:inline">Movie</span>
</>
) : (
<>
<IconDeviceTv
aria-hidden
className="size-3.5 translate-y-[-0.5px]"
/>
<span className="hidden md:inline">TV</span>
</>
)}
</div>
{title.contentRating && (
<div className="inline-flex border border-muted-foreground/50 px-1.5 font-medium text-[13px]">
{title.contentRating}
</div>
)}
{year && <span>{year}</span>}
{title.genres.length > 0 && (
<GenreCollapse genres={title.genres} />
)}
{title.voteAverage != null && title.voteAverage > 0 && (
<span className="inline-flex items-center gap-1 text-primary">
<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"
) && (
<span className="inline-flex items-center gap-1">
<StatusIcon status={title.status} />
{title.status}
</span>
)}
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
aria-label="View on TMDB"
className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40"
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
</div>
</div>
{title.overview && <ExpandableText text={title.overview} />}
{actions}
{children}
</div>
</div>
</>
);
}
const statusIcons: Record<string, React.ReactNode> = {
Ended: <IconCircleCheck className="size-3.5" />,
Canceled: <IconCircleX className="size-3.5" />,
"Returning Series": <IconRefresh className="size-3.5" />,
"In Production": <IconLoader className="size-3.5" />,
"Post Production": <IconLoader className="size-3.5" />,
Planned: <IconCalendarEvent className="size-3.5" />,
Pilot: <IconCalendarEvent className="size-3.5" />,
Rumored: <IconCalendarEvent className="size-3.5" />,
};
function StatusIcon({ status }: { status: string }) {
return statusIcons[status] ?? null;
}
function AmbientGlow({ palette }: { palette: ColorPalette | null }) {
if (!palette) return null;
const glowColor = palette.vibrant ?? palette.darkMuted ?? palette.muted;
return (
<>
{/* Mobile: single full-bleed color wash that flows from the backdrop edge-to-edge */}
{glowColor && (
<div
className="pointer-events-none absolute top-0 right-[calc(-50dvw+50%)] left-[calc(-50dvw+50%)] -z-10 h-[600px] md:hidden"
style={{
background: `radial-gradient(ellipse 100% 60% at 50% 0%, ${glowColor}18 0%, transparent 70%)`,
}}
/>
)}
{/* Desktop: multi-orb ambient glow with room to breathe */}
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 hidden h-[800px] md:block">
{palette.vibrant && (
<div
className="absolute top-16 -left-32 h-[500px] w-[500px] rounded-full opacity-[0.07] blur-[120px]"
style={{ background: palette.vibrant }}
/>
)}
{palette.darkMuted && (
<div
className="absolute top-48 -right-24 h-[400px] w-[600px] rounded-full opacity-[0.05] blur-[140px]"
style={{ background: palette.darkMuted }}
/>
)}
{palette.muted && (
<div
className="absolute top-[500px] left-1/3 h-[300px] w-[400px] rounded-full opacity-[0.04] blur-[100px]"
style={{ background: palette.muted }}
/>
)}
</div>
</>
);
}
@@ -0,0 +1,45 @@
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";
export function TitleKeyboardShortcuts() {
const progress = useProgress();
const { titleType } = useTitleContext();
const { userStatus } = useTitleUserInfo();
const { handleStatusChange, handleRating, handleWatchMovie } =
useTitleActions();
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
const enabled = !commandPaletteOpen;
// W: toggle watchlist (add if not in library, remove if in library)
useHotkey("W", () => handleStatusChange(userStatus ? null : "watchlist"), {
enabled,
});
useHotkey(
"M",
() => {
if (titleType === "movie") handleWatchMovie();
},
{ enabled },
);
useHotkey(
"Escape",
() => {
progress.start();
window.history.back();
},
{ enabled },
);
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 });
}
return null;
}
@@ -0,0 +1,36 @@
import type { Season } from "@sofa/api/schemas";
import { useState } from "react";
import { TitleContext } from "./title-context";
export function TitleProvider({
titleId,
titleType,
titleName,
seasons: initialSeasons,
children,
}: {
titleId: string;
titleType: "movie" | "tv";
titleName: string;
seasons: Season[];
children: React.ReactNode;
}) {
const [seasons, setSeasons] = useState(initialSeasons);
const [watchingEp, setWatchingEp] = useState<string | null>(null);
return (
<TitleContext
value={{
titleId,
titleType,
titleName,
seasons,
setSeasons,
watchingEp,
setWatchingEp,
}}
>
{children}
</TitleContext>
);
}
@@ -0,0 +1,62 @@
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";
function RecommendationsSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-36" />
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
</div>
</div>
);
}
export function TitleRecommendations({ titleId }: { titleId: string }) {
const { data, isLoading } = useQuery(
orpc.titles.recommendations.queryOptions({ input: { id: titleId } }),
);
if (isLoading) return <RecommendationsSkeleton />;
if (!data || data.recommendations.length === 0) return null;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight">Recommended</h2>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{data.recommendations.slice(0, 12).map((rec, i) => (
<div
key={rec.id}
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
id={rec.id}
tmdbId={rec.tmdbId}
type={rec.type}
title={rec.title}
posterPath={rec.posterPath}
releaseDate={rec.releaseDate ?? rec.firstAirDate}
voteAverage={rec.voteAverage}
userStatus={data.userStatuses[rec.id]}
/>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,362 @@
import type { Season } from "@sofa/api/schemas";
import {
IconCheck,
IconChecks,
IconChevronDown,
IconChevronUp,
IconDeviceTvOld,
} from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useTitleContext, useTitleUserInfo } from "./title-context";
import { useTitleActions } from "./use-title-actions";
export function SeasonsSkeleton() {
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-7 w-28" />
</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 className="flex items-center justify-between p-4">
<Skeleton className="h-4 w-24" />
<div className="flex items-center gap-3">
<Skeleton className="hidden h-2 w-24 rounded-full sm:block" />
<Skeleton className="h-3 w-10" />
<Skeleton className="size-4" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
export function TitleSeasons({
seasons: streamedSeasons,
}: {
seasons?: Season[];
} = {}) {
const { seasons, setSeasons, watchingEp } = useTitleContext();
const { episodeWatches, userStatus } = useTitleUserInfo();
// When seasons are streamed via Suspense, sync them into context
useEffect(() => {
if (streamedSeasons && streamedSeasons.length > 0) {
setSeasons(streamedSeasons);
}
}, [streamedSeasons, setSeasons]);
const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]);
const {
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
handleMarkAllWatched,
} = useTitleActions();
const seasonProgress = useMemo(() => {
const map = new Map<string, number>();
for (const season of seasons) {
let count = 0;
for (const ep of season.episodes) {
if (watchedSet.has(ep.id)) count++;
}
map.set(season.id, count);
}
return map;
}, [seasons, watchedSet]);
const [openSeason, setOpenSeason] = useState<number | null>(null);
const [markAllOpen, setMarkAllOpen] = useState(false);
return (
<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" />
<h2 className="font-display text-2xl tracking-tight">Episodes</h2>
</div>
{userStatus !== "completed" && (
<AlertDialog open={markAllOpen} onOpenChange={setMarkAllOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="xs"
className="text-muted-foreground uppercase tracking-wider"
>
<IconChecks aria-hidden={true} className="size-3.5" />
Mark All Watched
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Mark all episodes as watched?
</AlertDialogTitle>
<AlertDialogDescription>
This will mark every episode of this show as watched. You can
undo this later by unmarking individual seasons.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
handleMarkAllWatched();
setMarkAllOpen(false);
}}
>
Mark All Watched
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="space-y-2">
{seasons.map((season) => {
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;
return (
<div
key={season.id}
className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
>
{/* biome-ignore lint/a11y/useSemanticElements: contains nested buttons */}
<div
role="button"
tabIndex={0}
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"
>
<div className="flex items-center gap-3">
<span className="font-medium">
{season.name ?? `Season ${season.seasonNumber}`}
</span>
</div>
<div className="flex items-center gap-3">
{totalCount > 0 && (
<div className="hidden w-24 sm:block sm:group-hover/season:hidden">
<Progress value={progressPercent} />
</div>
)}
{totalCount > 0 && watchedCount < totalCount && (
<Button
variant="ghost"
size="xs"
onClick={(e) => {
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"
>
<IconChecks
aria-hidden={true}
className="size-3.5 sm:hidden"
/>
<span className="hidden sm:inline">Watch all</span>
</Button>
)}
{totalCount > 0 && watchedCount === totalCount && (
<Button
variant="ghost"
size="xs"
onClick={(e) => {
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"
>
<IconChecks
aria-hidden={true}
className="size-3.5 sm:hidden"
/>
<span className="hidden sm:inline">Unwatch all</span>
</Button>
)}
{totalCount > 0 && (
<span className="font-mono text-muted-foreground text-xs tabular-nums">
{watchedCount}/{totalCount}
</span>
)}
{isOpen ? (
<IconChevronUp
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
) : (
<IconChevronDown
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
)}
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="overflow-hidden border-border/50 border-t"
>
{season.episodes.map((ep) => {
const isWatched = watchedSet.has(ep.id);
const { stillPath } = ep;
return (
<div
key={ep.id}
className={`border-border/30 border-b transition-colors last:border-b-0 ${isWatched ? "opacity-60" : ""}`}
>
{/* Mobile: still banner above episode info */}
{stillPath && (
<div className="relative aspect-video w-full overflow-hidden bg-muted sm:hidden">
<img
src={stillPath}
alt={ep.name ?? ""}
width={600}
height={338}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
<span className="absolute bottom-2 left-3 font-mono text-[10px] text-white/70">
E{String(ep.episodeNumber).padStart(2, "0")}
</span>
</div>
)}
<div className="flex gap-3 px-4 py-3">
<button
type="button"
aria-label={`Mark episode ${ep.episodeNumber} as ${isWatched ? "unwatched" : "watched"}`}
onClick={() =>
handleWatchEpisode(
ep.id,
season.seasonNumber,
ep.episodeNumber,
isWatched,
)
}
disabled={watchingEp === ep.id}
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 transition-all ${
isWatched
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40 bg-muted-foreground/5 hover:border-primary/70 hover:bg-primary/10"
}`}
>
{isWatched && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 500,
damping: 15,
}}
>
<IconCheck className="size-3.5" />
</motion.div>
)}
</button>
{/* Desktop: inline thumbnail */}
{stillPath && (
<div className="hidden h-14 w-24 shrink-0 overflow-hidden rounded-md bg-muted sm:block">
<img
src={stillPath}
alt={ep.name ?? ""}
width={300}
height={169}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
</div>
)}
<div className="min-w-0 flex-1">
<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" : ""}`}
>
E{String(ep.episodeNumber).padStart(2, "0")}
</span>
{stillPath && (
<span className="hidden sm:inline"> </span>
)}
<span className="font-medium">
{ep.name ?? "Untitled"}
</span>
</p>
<p className="text-muted-foreground text-xs">
{ep.airDate
? format(parseISO(ep.airDate), "MMM d, yyyy")
: ""}
{ep.airDate && ep.runtimeMinutes ? " · " : ""}
{ep.runtimeMinutes
? `${ep.runtimeMinutes}m`
: ""}
</p>
{ep.overview && (
<p className="mt-1 line-clamp-2 text-muted-foreground/70 text-xs leading-relaxed">
{ep.overview}
</p>
)}
</div>
</div>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { useEffect } from "react";
const THEME_PROPERTIES = [
"--primary",
"--ring",
"--primary-foreground",
"--status-watching",
] as const;
export function TitleTheme({ style }: { style: Record<string, string> }) {
useEffect(() => {
const root = document.documentElement;
const entries = THEME_PROPERTIES.filter((key) => key in style);
for (const key of entries) {
root.style.setProperty(key, style[key]);
}
return () => {
for (const key of entries) {
root.style.removeProperty(key);
}
};
}, [style]);
return null;
}
@@ -0,0 +1,62 @@
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"));
const MediaThemeSutro = lazy(() => import("@player.style/sutro/react"));
export function TrailerDialog({
videoKey,
variant = "badge",
}: {
videoKey: string;
variant?: "badge" | "backdrop";
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
{variant === "backdrop" ? (
<button
type="button"
aria-label="Play trailer"
onClick={() => setOpen(true)}
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
>
<IconPlayerPlayFilled className="size-6 text-white drop-shadow-lg sm:size-8" />
</button>
) : (
<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"
>
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
Trailer
</button>
)}
<DialogContent className="overflow-hidden border-white/10 bg-black p-0 sm:max-w-4xl">
<DialogTitle className="sr-only">Trailer</DialogTitle>
<div className="aspect-video w-full">
<Suspense>
<MediaThemeSutro
style={
{
width: "100%",
height: "100%",
} as React.CSSProperties
}
>
<YoutubeVideo
slot="media"
src={`https://www.youtube-nocookie.com/watch?v=${videoKey}`}
playsInline
crossOrigin="anonymous"
/>
</MediaThemeSutro>
</Suspense>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,376 @@
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 { useTitleContext } from "./title-context";
type UserInfo = {
status: "watchlist" | "in_progress" | "completed" | null;
rating: number | null;
episodeWatches: string[];
};
export function useTitleActions() {
const { titleId, titleName, seasons, setWatchingEp } = useTitleContext();
const queryClient = useQueryClient();
const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } });
const getUserInfo = useCallback(
() =>
queryClient.getQueryData<UserInfo>(userInfoKey) ?? {
status: null,
rating: null,
episodeWatches: [],
},
[queryClient, userInfoKey],
);
const setUserInfo = useCallback(
(updater: (old: UserInfo) => UserInfo) => {
queryClient.setQueryData<UserInfo>(userInfoKey, (old) =>
updater(old ?? { status: null, rating: null, episodeWatches: [] }),
);
},
[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 watchEpMutation = useMutation(orpc.episodes.watch.mutationOptions());
const watchSeasonMutation = useMutation(orpc.seasons.watch.mutationOptions());
const unwatchSeasonMutation = useMutation(
orpc.seasons.unwatch.mutationOptions(),
);
const watchAllMutation = useMutation(orpc.titles.watchAll.mutationOptions());
const catchUp = useCallback(
async (episodeIds: string[]) => {
const prev = getUserInfo();
const newWatchSet = new Set(prev.episodeWatches);
for (const id of episodeIds) newWatchSet.add(id);
const newWatches = [...newWatchSet];
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status: allWatched ? "completed" : old.status,
}));
try {
await batchWatchMutation.mutateAsync({ episodeIds });
toast.success(
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`,
);
} catch {
setUserInfo((old) => ({
...old,
episodeWatches: prev.episodeWatches,
status: prev.status,
}));
toast.error("Failed to catch up");
}
},
[getUserInfo, setUserInfo, seasons, batchWatchMutation],
);
const handleStatusChange = useCallback(
async (status: string | null) => {
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({
...old,
status:
status === "watchlist"
? "in_progress"
: (status as UserInfo["status"]),
}));
try {
await updateStatusMutation.mutateAsync({
id: titleId,
status: status ? "in_progress" : null,
});
toast.success(status ? "Added to watchlist" : "Removed from library");
} catch {
setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error("Failed to update status");
}
},
[getUserInfo, setUserInfo, titleId, updateStatusMutation],
);
const handleRating = useCallback(
async (ratingStars: number) => {
const prevRating = getUserInfo().rating;
setUserInfo((old) => ({ ...old, rating: ratingStars }));
try {
await updateRatingMutation.mutateAsync({
id: titleId,
stars: ratingStars,
});
toast.success(
ratingStars > 0
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
: "Rating removed",
);
} catch {
setUserInfo((old) => ({ ...old, rating: prevRating }));
toast.error("Failed to update rating");
}
},
[getUserInfo, setUserInfo, titleId, updateRatingMutation],
);
const handleWatchMovie = useCallback(async () => {
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({ ...old, status: "completed" }));
try {
await watchMovieMutation.mutateAsync({ id: titleId });
toast.success(`Marked "${titleName}" as watched`);
} catch {
setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error("Failed to mark as watched");
}
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation]);
const handleWatchEpisode = useCallback(
async (
episodeId: string,
seasonNum: number,
epNum: number,
isWatched: boolean,
) => {
setWatchingEp(episodeId);
if (isWatched) {
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({
...old,
episodeWatches: old.episodeWatches.filter((id) => id !== episodeId),
status: old.status === "completed" ? "in_progress" : old.status,
}));
try {
await unwatchEpMutation.mutateAsync({ id: episodeId });
toast.success(`Unwatched S${seasonNum} E${epNum}`);
} catch {
setUserInfo((old) => ({
...old,
episodeWatches: prevWatches,
status: prevStatus,
}));
toast.error("Failed to unmark episode");
}
} else {
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const currentWatches = prevWatches;
const newWatches = currentWatches.includes(episodeId)
? currentWatches
: [...currentWatches, episodeId];
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status:
old.status === null || old.status === "watchlist"
? "in_progress"
: old.status,
}));
try {
await watchEpMutation.mutateAsync({ id: episodeId });
const watchedSet = new Set(getUserInfo().episodeWatches);
const previousUnwatched: string[] = [];
for (const s of seasons) {
for (const ep of s.episodes) {
if (
s.seasonNumber < seasonNum ||
(s.seasonNumber === seasonNum && ep.episodeNumber < epNum)
) {
if (!watchedSet.has(ep.id) && ep.id !== episodeId) {
previousUnwatched.push(ep.id);
}
}
}
}
if (previousUnwatched.length > 0) {
const count = previousUnwatched.length;
toast.success(`Watched S${seasonNum} E${epNum}`, {
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
action: {
label: "Catch up",
onClick: () => catchUp(previousUnwatched),
},
duration: 8000,
});
} else {
toast.success(`Watched S${seasonNum} E${epNum}`);
}
} catch {
setUserInfo((old) => ({
...old,
episodeWatches: prevWatches,
status: prevStatus,
}));
toast.error("Failed to mark episode");
}
}
setWatchingEp(null);
},
[
getUserInfo,
setUserInfo,
setWatchingEp,
seasons,
catchUp,
unwatchEpMutation,
watchEpMutation,
],
);
const handleMarkSeason = useCallback(
async (season: Season) => {
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const watchedSet = new Set(prevWatches);
const unwatched = season.episodes.filter((ep) => !watchedSet.has(ep.id));
if (unwatched.length === 0) return;
const newWatchSet = new Set(watchedSet);
for (const ep of unwatched) newWatchSet.add(ep.id);
const newWatches = [...newWatchSet];
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status: allWatched
? "completed"
: old.status === null || old.status === "watchlist"
? "in_progress"
: old.status,
}));
try {
await watchSeasonMutation.mutateAsync({ id: season.id });
const currentWatchSet = new Set(getUserInfo().episodeWatches);
const previousUnwatched: string[] = [];
for (const s of seasons) {
if (s.seasonNumber < season.seasonNumber) {
for (const ep of s.episodes) {
if (!currentWatchSet.has(ep.id)) {
previousUnwatched.push(ep.id);
}
}
}
}
const seasonLabel = season.name ?? `Season ${season.seasonNumber}`;
if (previousUnwatched.length > 0) {
const count = previousUnwatched.length;
toast.success(`Watched all of ${seasonLabel}`, {
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
action: {
label: "Catch up",
onClick: () => catchUp(previousUnwatched),
},
duration: 8000,
});
} else {
toast.success(`Watched all of ${seasonLabel}`);
}
} catch {
setUserInfo((old) => ({
...old,
episodeWatches: prevWatches,
status: prevStatus,
}));
toast.error("Failed to mark some episodes");
}
},
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation],
);
const handleUnmarkSeason = useCallback(
async (season: Season) => {
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
setUserInfo((old) => ({
...old,
episodeWatches: old.episodeWatches.filter((id) => !seasonEpIds.has(id)),
status: old.status === "completed" ? "in_progress" : old.status,
}));
try {
await unwatchSeasonMutation.mutateAsync({ id: season.id });
toast.success(
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
} catch {
setUserInfo((old) => ({
...old,
episodeWatches: prevWatches,
status: prevStatus,
}));
toast.error("Failed to unmark some episodes");
}
},
[getUserInfo, setUserInfo, unwatchSeasonMutation],
);
const handleMarkAllWatched = useCallback(async () => {
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
setUserInfo((old) => ({
...old,
episodeWatches: allEpIds,
status: "completed",
}));
try {
await watchAllMutation.mutateAsync({ id: titleId });
toast.success("Marked all episodes as watched");
} catch {
setUserInfo((old) => ({
...old,
episodeWatches: prevWatches,
status: prevStatus,
}));
toast.error("Failed to mark all episodes as watched");
}
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation]);
return {
handleStatusChange,
handleRating,
handleWatchMovie,
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
handleMarkAllWatched,
};
}
+29
View File
@@ -0,0 +1,29 @@
export function TmdbLogo({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 190.24 81.52"
className={className}
role="img"
aria-label="TMDB"
>
<defs>
<linearGradient
id="tmdb-grad"
x2="190.24"
y1="40.76"
y2="40.76"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#90cea1" />
<stop offset=".56" stopColor="#3cbec9" />
<stop offset="1" stopColor="#00b3e5" />
</linearGradient>
</defs>
<path
fill="url(#tmdb-grad)"
d="M105.67 36.06h66.9a17.67 17.67 0 0 0 17.67-17.66A17.67 17.67 0 0 0 172.57.73h-66.9A17.67 17.67 0 0 0 88 18.4a17.67 17.67 0 0 0 17.67 17.66m-88 45h76.9a17.67 17.67 0 0 0 17.67-17.66 17.67 17.67 0 0 0-17.67-17.67h-76.9A17.67 17.67 0 0 0 0 63.4a17.67 17.67 0 0 0 17.67 17.66m-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1Zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8Zm113.92 20.25a15.1 15.1 0 0 0-4.52-5.52 18.6 18.6 0 0 0-6.68-3.08 33.5 33.5 0 0 0-8.07-1h-11.7v35.4h12.75a24.6 24.6 0 0 0 7.55-1.15 19.3 19.3 0 0 0 6.35-3.32 16.3 16.3 0 0 0 4.37-5.5 16.9 16.9 0 0 0 1.63-7.58 18.5 18.5 0 0 0-1.68-8.25M145 68.6a8.8 8.8 0 0 1-2.64 3.4 10.7 10.7 0 0 1-4 1.82 21.6 21.6 0 0 1-5 .55h-4.05v-21h4.6a17 17 0 0 1 4.67.63 11.7 11.7 0 0 1 3.88 1.87A9.14 9.14 0 0 1 145 59a9.9 9.9 0 0 1 1 4.52 11.9 11.9 0 0 1-1 5.08m44.63-.13a8 8 0 0 0-1.58-2.62 8.4 8.4 0 0 0-2.42-1.85 10.3 10.3 0 0 0-3.17-1v-.1a9.2 9.2 0 0 0 4.42-2.82 7.43 7.43 0 0 0 1.68-5 8.4 8.4 0 0 0-1.15-4.65 8.1 8.1 0 0 0-3-2.72 12.6 12.6 0 0 0-4.18-1.3 33 33 0 0 0-4.62-.33h-13.2v35.4h14.5a22.4 22.4 0 0 0 4.72-.5 13.5 13.5 0 0 0 4.28-1.65 9.4 9.4 0 0 0 3.1-3 8.5 8.5 0 0 0 1.2-4.68 9.4 9.4 0 0 0-.55-3.18Zm-19.42-15.75h5.3a10 10 0 0 1 1.85.18 6.2 6.2 0 0 1 1.7.57 3.4 3.4 0 0 1 1.22 1.13 3.2 3.2 0 0 1 .48 1.82 3.63 3.63 0 0 1-.43 1.8 3.4 3.4 0 0 1-1.12 1.2 4.9 4.9 0 0 1-1.58.65 7.5 7.5 0 0 1-1.77.2h-5.65Zm11.72 20a3.9 3.9 0 0 1-1.22 1.3 4.6 4.6 0 0 1-1.68.7 8.2 8.2 0 0 1-1.82.2h-7v-8h5.9a15 15 0 0 1 2 .15 8.5 8.5 0 0 1 2.05.55 4 4 0 0 1 1.57 1.18 3.1 3.1 0 0 1 .63 2 3.7 3.7 0 0 1-.43 1.92"
/>
</svg>
);
}
+80
View File
@@ -0,0 +1,80 @@
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,
)}
{...props}
/>
);
}
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)}
{...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",
className,
)}
{...props}
>
{children}
<IconChevronDown
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<IconChevronUp
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
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"
{...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",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+184
View File
@@ -0,0 +1,184 @@
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";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.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} />
);
}
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",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
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",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<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",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<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",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+76
View File
@@ -0,0 +1,76 @@
import { cva, type VariantProps } from "class-variance-authority";
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",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<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",
className,
)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-1.5 right-2", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<"div"> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
"--ratio": ratio,
} as React.CSSProperties
}
className={cn("relative aspect-(--ratio)", className)}
{...props}
/>
);
}
export { AspectRatio };
+107
View File
@@ -0,0 +1,107 @@
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
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",
className,
)}
{...props}
/>
);
}
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,
)}
{...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",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<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",
"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",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};
+52
View File
@@ -0,0 +1,52 @@
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 { 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!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/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",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
+123
View File
@@ -0,0 +1,123 @@
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}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"wrap-break-word flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs/relaxed",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props,
),
render,
state: {
slot: "breadcrumb-link",
},
});
}
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)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <IconChevronRight />}
</li>
);
}
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,
)}
{...props}
>
<IconDots />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,87 @@
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";
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
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",
className,
),
},
props,
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
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",
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
+56
View File
@@ -0,0 +1,56 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+225
View File
@@ -0,0 +1,225 @@
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react";
import * as React from "react";
import {
type DayButton,
DayPicker,
getDefaultClassNames,
type Locale,
} from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)]",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months,
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 select-none rounded-(--cell-radius) font-normal text-[0.8rem] text-muted-foreground",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header,
),
week_number: cn(
"select-none text-[0.8rem] text-muted-foreground",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none rounded-(--cell-radius) p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day,
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end,
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<IconChevronLeft className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<IconChevronRight
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<IconChevronDown className={cn("size-4", className)} {...props} />
);
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 font-normal leading-none data-[range-end=true]:rounded-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-end=true]:bg-primary data-[range-middle=true]:bg-muted data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+100
View File
@@ -0,0 +1,100 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
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",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-medium text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
);
}
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,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3",
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+359
View File
@@ -0,0 +1,359 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
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",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<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,
);
if (!colorConfig.length) {
return null;
}
return (
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: shadcn generated chart theming
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: RechartsPrimitive.TooltipContentProps<
number | string | ReadonlyArray<number | string>,
number | string
> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
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",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<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",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-medium font-mono text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
payload?: RechartsPrimitive.LegendPayload[];
verticalAlign?: "top" | "bottom" | "middle";
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
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.payload
: undefined;
let configLabelKey: string = key;
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;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+25
View File
@@ -0,0 +1,25 @@
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) {
return (
<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",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<IconCheck />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+297
View File
@@ -0,0 +1,297 @@
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,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-3.5", className)}
{...props}
>
{children}
<IconChevronDown className="pointer-events-none size-3.5 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<IconX className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
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",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...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",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
}
>
<IconCheck className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...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)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<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",
className,
)}
{...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)}
{...props}
/>
);
}
function ComboboxChips({
className,
...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",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<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",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<IconX className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};
+190
View File
@@ -0,0 +1,190 @@
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { Command as CommandPrimitive } from "cmdk";
import type * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
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",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className,
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: 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">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-[13px] leading-relaxed outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<IconSearch className="size-3.5 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
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",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-[13px] leading-relaxed", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<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",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<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",
className,
)}
{...props}
>
{children}
<IconCheck className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
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",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+269
View File
@@ -0,0 +1,269 @@
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) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
);
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<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",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<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,
)}
{...props}
/>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
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",
className,
)}
{...props}
/>
);
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
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",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
);
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.CheckboxItem
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",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<ContextMenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.RadioItem
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",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<ContextMenuPrimitive.RadioItemIndicator>
<IconCheck />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
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)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+153
View File
@@ -0,0 +1,153 @@
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";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...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",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<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",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<IconX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
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>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-medium text-sm", className)}
{...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",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+4
View File
@@ -0,0 +1,4 @@
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
+129
View File
@@ -0,0 +1,129 @@
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/80 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col overscroll-contain bg-transparent p-2 text-xs/relaxed before:absolute before:inset-2 before:-z-10 before:rounded-xl before:border before:border-border before:bg-background data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-1 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,269 @@
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) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<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",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<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,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
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",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
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",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<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",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
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",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
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",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
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)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+101
View File
@@ -0,0 +1,101 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-1", className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
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",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("font-medium text-sm tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground text-xs/relaxed [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<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",
className,
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};
+244
View File
@@ -0,0 +1,244 @@
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";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-2 font-medium data-[variant=label]:text-xs/relaxed data-[variant=legend]:text-sm",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-4 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
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",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
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,
)}
{...props}
/>
);
}
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",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<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",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-xs/relaxed group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
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>
),
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("font-normal text-destructive text-xs/relaxed", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
+49
View File
@@ -0,0 +1,49 @@
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
import { cn } from "@/lib/utils";
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<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",
className,
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
+157
View File
@@ -0,0 +1,157 @@
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";
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",
className,
)}
{...props}
/>
);
}
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",
{
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]",
"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":
"order-last w-full justify-start px-2 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...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"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 rounded-md text-xs/relaxed shadow-none",
{
variants: {
size: {
xs: "h-5 gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1 [&>svg:not([class*='size-'])]:size-3",
sm: "",
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+20
View File
@@ -0,0 +1,20 @@
import { Input as InputPrimitive } from "@base-ui/react/input";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
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",
className,
)}
{...props}
/>
);
}
export { Input };
+201
View File
@@ -0,0 +1,201 @@
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"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className,
)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
);
}
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",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
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",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props,
),
render,
state: {
slot: "item",
variant,
size,
},
});
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-8 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0.5 [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
}
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,
)}
{...props}
/>
);
}
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,
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };
+19
View File
@@ -0,0 +1,19 @@
import type * as React from "react";
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",
className,
)}
{...props}
/>
);
}
export { Label };
+281
View File
@@ -0,0 +1,281 @@
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,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
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,
)}
{...props}
/>
);
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />;
}
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",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
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",
className,
)}
{...props}
/>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
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!",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
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",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />;
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
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",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean;
}) {
return (
<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,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-[0.625rem] text-muted-foreground tracking-widest group-focus/menubar-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuSubTrigger
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",
className,
)}
{...props}
/>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<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",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
@@ -0,0 +1,170 @@
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({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
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",
);
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<IconChevronDown
className="relative top-px ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
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",
className,
)}
{...props}
/>
);
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className,
)}
{...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.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
);
}
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",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<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",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Icon>
);
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
};
+131
View File
@@ -0,0 +1,131 @@
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";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
);
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-2!", className)}
{...props}
>
<IconChevronLeft data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
);
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-2!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<IconChevronRight data-icon="inline-end" />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-7 items-center justify-center [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
<IconDots />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};
+88
View File
@@ -0,0 +1,88 @@
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<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",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-xs", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium text-sm", className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};
+81
View File
@@ -0,0 +1,81 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "@/lib/utils";
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
);
}
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",
className,
)}
data-slot="progress-track"
{...props}
/>
);
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
);
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("font-medium text-xs/relaxed", className)}
data-slot="progress-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,
)}
data-slot="progress-value"
{...props}
/>
);
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
};
@@ -0,0 +1,36 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<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",
className,
)}
{...props}
>
<RadioPrimitive.Indicator
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" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
);
}
export { RadioGroup, RadioGroupItem };
@@ -0,0 +1,83 @@
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
scrollFade = true,
scrollbarGutter = false,
hideScrollbar = false,
scrollRef,
contentRef,
...props
}: ScrollAreaPrimitive.Root.Props & {
/** Fade the edges of the scroll area to indicate scrollability */
scrollFade?: boolean;
/** Leave extra space for the scrollbar, instead of it covering the content */
scrollbarGutter?: boolean;
/** Completely hide scrollbars (useful for touch/gesture-only scroll areas) */
hideScrollbar?: boolean;
/** Ref for the outer viewport element */
scrollRef?: React.Ref<HTMLDivElement>;
/** Ref for the inner content element */
contentRef?: React.Ref<HTMLDivElement>;
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative flex min-h-0 min-w-0 overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
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",
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",
)}
>
<ScrollAreaPrimitive.Content
data-slot="scroll-area-content"
ref={contentRef}
>
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{!hideScrollbar && (
<>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</>
)}
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.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",
className,
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-black/40 dark:bg-white/40"
/>
</ScrollAreaPrimitive.Scrollbar>
);
}
export { ScrollArea, ScrollBar };
+207
View File
@@ -0,0 +1,207 @@
import { Select as SelectPrimitive } from "@base-ui/react/select";
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconSelector,
} from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<IconSelector className="pointer-events-none size-3.5 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
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",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
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)}
{...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",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
}
>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
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,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<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",
className,
)}
{...props}
>
<IconChevronUp />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<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",
className,
)}
{...props}
>
<IconChevronDown />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+23
View File
@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
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",
className,
)}
{...props}
/>
);
}
export { Separator };
+131
View File
@@ -0,0 +1,131 @@
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";
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<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",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
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",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<IconX />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+729
View File
@@ -0,0 +1,729 @@
import { mergeProps } from "@base-ui/react/merge-props";
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";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// 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);
}, [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)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// 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,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
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",
// 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)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<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"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<IconLayoutSidebar />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
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",
"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",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<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",
className,
)}
{...props}
/>
);
}
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,
)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
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)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn(
"relative flex w-full min-w-0 flex-col px-2 py-1",
className,
)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
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",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
});
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
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",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
});
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-xs", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-px", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
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",
{
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))]",
},
size: {
default: "h-8 text-xs",
sm: "h-7 text-xs",
lg: "h-12 text-xs group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props,
),
render: !tooltip ? render : TooltipTrigger,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
});
if (!tooltip) {
return comp;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: "button",
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",
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",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
});
}
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",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
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"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<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",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: "a",
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",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
});
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

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