feat(awards): add AwardCard and RibbonRack components for award display
This commit is contained in:
parent
9e91203330
commit
b060b967c0
10 changed files with 264 additions and 76 deletions
1
bun.lock
1
bun.lock
|
|
@ -23,6 +23,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
|
|
||||||
BIN
public/images/ribbon-overlay.png
Normal file
BIN
public/images/ribbon-overlay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -4,7 +4,6 @@ import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types";
|
import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types";
|
||||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
|
||||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
|
|
@ -32,6 +31,8 @@ import {
|
||||||
ItemMedia,
|
ItemMedia,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
|
import AwardCard from "@/components/frontend/account/AwardCard";
|
||||||
|
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
||||||
|
|
||||||
interface ProfilePageProps {
|
interface ProfilePageProps {
|
||||||
params: Promise<{ username: string }>;
|
params: Promise<{ username: string }>;
|
||||||
|
|
@ -151,6 +152,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
<span className="text-border">|</span>
|
<span className="text-border">|</span>
|
||||||
<span className="font-mono text-xs">@{user.username}</span>
|
<span className="font-mono text-xs">@{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<RibbonRack awards={awards} />
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden max-w-xs">
|
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden max-w-xs">
|
||||||
<div
|
<div
|
||||||
|
|
@ -392,39 +394,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
{awards.map((award) => {
|
{awards.map((award) => {
|
||||||
const awardImage =
|
const awardImage =
|
||||||
award.image && typeof award.image === "object" ? (award.image as Media) : null;
|
award.image && typeof award.image === "object" ? (award.image as Media) : null;
|
||||||
return (
|
return <AwardCard key={award.id} awardImage={awardImage} award={award} />;
|
||||||
<Item key={award.id} variant="outline">
|
|
||||||
<ItemHeader>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{awardImage?.url && (
|
|
||||||
<img
|
|
||||||
src={awardImage.url}
|
|
||||||
alt={award.name}
|
|
||||||
className="size-8 object-contain"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<ItemTitle>{award.name}</ItemTitle>
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
|
|
||||||
{award.type ?? "commendation"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemDescription className="line-clamp-3">
|
|
||||||
{award.description
|
|
||||||
? convertLexicalToPlaintext({ data: award.description }).slice(0, 120)
|
|
||||||
: "No description."}
|
|
||||||
</ItemDescription>
|
|
||||||
{award.experiencePoints != null && (
|
|
||||||
<span className="text-xs font-mono text-muted-foreground mt-1">
|
|
||||||
+{award.experiencePoints} XP
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ type Stripe = {
|
||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RibbonDesign = {
|
export type RibbonDesign = {
|
||||||
stripes: Stripe[];
|
stripes: Stripe[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
mirrored: boolean;
|
mirrored: boolean;
|
||||||
|
|
@ -27,7 +27,7 @@ type RibbonDesign = {
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_RIBBON: RibbonDesign = {
|
export const DEFAULT_RIBBON: RibbonDesign = {
|
||||||
stripes: [
|
stripes: [
|
||||||
{ color: "#b91c1c", width: 30 },
|
{ color: "#b91c1c", width: 30 },
|
||||||
{ color: "#f5f5f4", width: 5 },
|
{ color: "#f5f5f4", width: 5 },
|
||||||
|
|
@ -62,16 +62,20 @@ const MILITARY_COLORS: { name: string; hex: string }[] = [
|
||||||
{ name: "Black", hex: "#18181b" },
|
{ name: "Black", hex: "#18181b" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
|
export const RIBBON_DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
|
||||||
star: { label: "Star", symbol: "\u2605" },
|
star: { label: "Star", symbol: "\u2605" },
|
||||||
oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" },
|
oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" },
|
||||||
arrowUp: { label: "Arrow", symbol: "\u2191" },
|
arrowUp: { label: "Arrow", symbol: "\u2191" },
|
||||||
campaignClasp: { label: "Clasp", symbol: "\u2015" },
|
campaignClasp: { label: "Clasp", symbol: "\u2015" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RIBBON_ASPECT = 3.667;
|
export const RIBBON_CONSTANTS = {
|
||||||
const RIBBON_WIDTH = 366;
|
RIBBON_ASPECT: 3.667,
|
||||||
const PREVIEW_HEIGHT = 80;
|
RIBBON_WIDTH: 366,
|
||||||
|
PREVIEW_HEIGHT: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { RIBBON_ASPECT, RIBBON_WIDTH, PREVIEW_HEIGHT } = RIBBON_CONSTANTS;
|
||||||
|
|
||||||
// ─── Preview ────────────────────────────────────────────────────────
|
// ─── Preview ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -103,7 +107,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{devices.map((device) => {
|
{devices.map((device) => {
|
||||||
const info = DEVICE_INFO[device.type];
|
const info = RIBBON_DEVICE_INFO[device.type];
|
||||||
const cx = (device.position / 100) * viewWidth;
|
const cx = (device.position / 100) * viewWidth;
|
||||||
const cy = (device.positionY / 100) * viewHeight;
|
const cy = (device.positionY / 100) * viewHeight;
|
||||||
const fontSize = Math.min(viewHeight * 0.45, 24);
|
const fontSize = Math.min(viewHeight * 0.45, 24);
|
||||||
|
|
@ -218,8 +222,8 @@ function DeviceEditor({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
|
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => {
|
{(Object.keys(RIBBON_DEVICE_INFO) as DeviceType[]).map((type) => {
|
||||||
const info = DEVICE_INFO[type];
|
const info = RIBBON_DEVICE_INFO[type];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
|
|
@ -235,7 +239,7 @@ function DeviceEditor({
|
||||||
{devices.length > 0 && (
|
{devices.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{devices.map((device) => {
|
{devices.map((device) => {
|
||||||
const info = DEVICE_INFO[device.type];
|
const info = RIBBON_DEVICE_INFO[device.type];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={device.id}
|
key={device.id}
|
||||||
|
|
|
||||||
50
src/components/frontend/account/AwardCard.tsx
Normal file
50
src/components/frontend/account/AwardCard.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Award, Media } from "@/payload-types";
|
||||||
|
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
|
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
|
||||||
|
|
||||||
|
export default function AwardCard(props: {
|
||||||
|
awardImage: Media | null;
|
||||||
|
award: Award;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader className="flex flex-col items-center text-center">
|
||||||
|
{props.award.ribbonDesign && (
|
||||||
|
<RibbonViewer
|
||||||
|
ribbonDesign={props.award.ribbonDesign as RibbonDesign}
|
||||||
|
showRibbonOnly={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{props.awardImage?.url && (
|
||||||
|
<img
|
||||||
|
src={props.awardImage.url}
|
||||||
|
alt={props.award.name}
|
||||||
|
className="size-8 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ItemTitle>{props.award.name}</ItemTitle>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
|
||||||
|
{props.award.type ?? "commendation"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemDescription className="line-clamp-3 text-center">
|
||||||
|
{props.award.description
|
||||||
|
? convertLexicalToPlaintext({ data: props.award.description }).slice(0, 120)
|
||||||
|
: "No description."}
|
||||||
|
</ItemDescription>
|
||||||
|
{props.award.experiencePoints != null && (
|
||||||
|
<span className="text-xs font-mono text-muted-foreground mt-1">
|
||||||
|
+{props.award.experiencePoints} XP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/frontend/account/RibbonRack.tsx
Normal file
36
src/components/frontend/account/RibbonRack.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
|
||||||
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Award } from "@/payload-types";
|
||||||
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
|
||||||
|
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row w-52 gap-1">
|
||||||
|
{awards
|
||||||
|
.filter((award) => award.ribbonDesign !== null)
|
||||||
|
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
|
||||||
|
.map((award, key) => {
|
||||||
|
const ribbon = award.ribbonDesign as RibbonDesign;
|
||||||
|
return (
|
||||||
|
<Tooltip key={key}>
|
||||||
|
<TooltipTrigger className="w-full hover:scale-110 transition-transform">
|
||||||
|
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex flex-row items-center justify-between gap-3">
|
||||||
|
<span className="font-bold text-lg">{award.name}</span>
|
||||||
|
<span className="text-amber-400/75">{award.experiencePoints} XP</span>
|
||||||
|
</div>
|
||||||
|
<p className="opacity-75">
|
||||||
|
{convertLexicalToPlaintext({ data: award.description })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
src/components/frontend/account/RibbonViewer.tsx
Normal file
73
src/components/frontend/account/RibbonViewer.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RIBBON_DEVICE_INFO,
|
||||||
|
RIBBON_CONSTANTS,
|
||||||
|
RibbonDesign,
|
||||||
|
DEFAULT_RIBBON,
|
||||||
|
} from "@/components/admin/awards/RibbonDesigner";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const RibbonViewer = ({ ribbonDesign, showRibbonOnly }: { ribbonDesign: RibbonDesign, showRibbonOnly?: boolean }) => {
|
||||||
|
const initial: RibbonDesign =
|
||||||
|
ribbonDesign && typeof ribbonDesign === "object" && Array.isArray((ribbonDesign as RibbonDesign).stripes)
|
||||||
|
? (ribbonDesign as RibbonDesign)
|
||||||
|
: DEFAULT_RIBBON;
|
||||||
|
|
||||||
|
const [design] = useState<RibbonDesign>(initial);
|
||||||
|
|
||||||
|
const { stripes, devices, mirrored } = design;
|
||||||
|
if (stripes.length === 0) return null;
|
||||||
|
|
||||||
|
const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes;
|
||||||
|
|
||||||
|
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
|
||||||
|
const viewWidth = RIBBON_CONSTANTS.RIBBON_WIDTH;
|
||||||
|
const viewHeight = RIBBON_CONSTANTS.PREVIEW_HEIGHT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
|
||||||
|
className={cn("h-auto max-h-20", {
|
||||||
|
"w-full rounded border border-zinc-700 bg-zinc-950": !showRibbonOnly
|
||||||
|
} )}
|
||||||
|
>
|
||||||
|
<rect x={0} y={0} width={viewWidth} height={viewHeight} rx={2} fill="#18181b" />
|
||||||
|
{(() => {
|
||||||
|
let x = 0;
|
||||||
|
return displayStripes.map((stripe, i) => {
|
||||||
|
const w = (stripe.width / totalWeight) * viewWidth;
|
||||||
|
const el = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />;
|
||||||
|
x += w;
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<image href="/images/ribbon-overlay.png" className="mix-blend-multiply w-full" />
|
||||||
|
|
||||||
|
{devices.map((device) => {
|
||||||
|
const info = RIBBON_DEVICE_INFO[device.type];
|
||||||
|
const cx = (device.position / 100) * viewWidth;
|
||||||
|
const cy = (device.positionY / 100) * viewHeight;
|
||||||
|
const fontSize = Math.min(viewHeight * 0.45, 24);
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={device.id}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill="#facc15"
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily="serif"
|
||||||
|
style={{ pointerEvents: "none" }}
|
||||||
|
className="text-shadow-sm text-shadow-black"
|
||||||
|
>
|
||||||
|
{info.symbol}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
src/components/ui/popover.tsx
Normal file
33
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
|
|
@ -1,37 +1,57 @@
|
||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider;
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
const Tooltip = TooltipPrimitive.Root;
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
const TooltipContent = React.forwardRef<
|
data-slot="tooltip-provider"
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
delayDuration={delayDuration}
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
|
||||||
arrowClassName?: string;
|
|
||||||
}
|
|
||||||
>(({ className, sideOffset = 4, arrowClassName, children, ...props }, ref) => (
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-[--radix-tooltip-content-transform-origin]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
/>
|
||||||
{arrowClassName && <TooltipPrimitive.Arrow className={arrowClassName} />}
|
)
|
||||||
{children}
|
}
|
||||||
</TooltipPrimitive.Content>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
));
|
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-background px-3 py-1.5 text-xs text-balance text-foreground fade-in-0 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-background fill-background" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue