mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -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't have an account?{" "}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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's what'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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's plugin catalog
|
||||
</li>
|
||||
<li>
|
||||
Go to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Dashboard > Plugins > 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 > 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 > 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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DirectionProvider,
|
||||
useDirection,
|
||||
} from "@base-ui/react/direction-provider";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user