import { useTranslation } from "react-i18next"; import { ChevronRight, Copy, CopyPlus, GripVertical, Pencil, Pin, Trash2, Share2, Upload, } from "lucide-react"; import { toast } from "sonner"; import { copyToClipboard } from "@/lib/clipboard"; import { ComponentSlot } from "@/shell/ActionSlot"; import type { Credential } from "@/types/ui-types"; import type { CredentialDensity, CredentialTrayTrigger, } from "@/types/credential-sidebar-preferences"; export function credentialMatchesQuery(cred: Credential, query: string) { return ( cred.name.toLowerCase().includes(query) || cred.username.toLowerCase().includes(query) || cred.tags?.some((t) => t.toLowerCase().includes(query)) ); } /** * Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in * HostItem.tsx -- one implementation with a token lookup instead of two * parallel JSX trees, so a future tweak only has to be made once. */ const CREDENTIAL_ITEM_DENSITY_TOKENS = { comfortable: { rowPadding: "pl-2.5 pr-2 py-2", nameTextSize: "text-[13px]", showUsernameRow: true, showTagsRow: true, }, compact: { rowPadding: "pl-2 pr-1.5 py-[5px]", nameTextSize: "text-[12px]", showUsernameRow: false, showTagsRow: false, }, } as const; export function CredentialItem({ cred, usedByCount = 0, query = "", stripeIndex = 0, isMenuOpen = false, onMenuOpenChange, isTrayOpen = false, onTrayOpenChange, onDragStart, onDragEnd, depth = 0, density = "comfortable", trayTrigger = "hover", showTags = true, arrangeMode = false, isDragging = false, onReorderDrop, isReorderHovered = false, reorderHoverEdge = null, onReorderHoverChange, onDeploy, onEdit, onClone, onDelete, onShare, }: { cred: Credential; usedByCount?: number; query?: string; stripeIndex?: number; isMenuOpen?: boolean; onMenuOpenChange?: (open: boolean) => void; isTrayOpen?: boolean; onTrayOpenChange?: (open: boolean) => void; onDragStart?: () => void; onDragEnd?: () => void; /** Nesting level when rendered in a flattened virtual list. */ depth?: number; density?: CredentialDensity; trayTrigger?: CredentialTrayTrigger; showTags?: boolean; /** When true (rearranging unlocked), the row can be dragged to reorder or * onto a folder header to move. */ arrangeMode?: boolean; /** True while this row is the one being dragged. */ isDragging?: boolean; onReorderDrop?: (position: "before" | "after") => void; /** Whether THIS row is the current reorder drop target -- lifted to the * parent tree so only one row can ever show the drop-indicator bar at a * time. See HostItem's identical prop for the full rationale. */ isReorderHovered?: boolean; reorderHoverEdge?: "before" | "after" | null; onReorderHoverChange?: (edge: "before" | "after" | null) => void; onDeploy: () => void; onEdit: () => void; onClone: () => void; onDelete: () => void; onShare?: () => void; }) { const { t } = useTranslation(); const reorderEdge = isReorderHovered ? reorderHoverEdge : null; const isKey = cred.type === "key"; const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density]; const isCompact = density === "compact"; const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; const alwaysShowTray = trayTrigger === "always"; const actionsOnly = trayTrigger === "actionsOnly"; const shouldUseClickTray = !alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly); const canDrag = arrangeMode && !isTouchOnly; const depthStyle = depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined; const trayButtonClass = "flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"; const connectionButtons = isKey ? ( <> > ) : null; // A recipient sees only what their grant allows: "manage" may edit and // re-share, "use" may only use. Cloning and deleting stay with the owner. const canEdit = !cred.isShared || cred.permissionLevel === "manage"; const canShare = !!onShare && (!cred.isShared || cred.permissionLevel === "manage"); const managementButtons = ( <> {canEdit && ( )} {canShare && ( )} {!cred.isShared && ( <> > )} > ); const trayOpenState = isTrayOpen || isMenuOpen; // A collapsed tray clipped to max-h-0 is still a flex item, so it earns the // text column's gap-1 and leaves a gap under every closed row. The negative // margin cancels it while keeping the element in flow to animate open. const trayCollapsed = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-1"}`; const trayVisibilityClass = alwaysShowTray || actionsOnly ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}` : shouldUseClickTray ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}` : `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`; return (