FilamentHoverCard.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import { useState, useRef, useEffect, type ReactNode } from 'react';
  2. import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
  3. interface FilamentData {
  4. vendor: 'Bambu Lab' | 'Generic';
  5. profile: string;
  6. colorName: string;
  7. colorHex: string | null;
  8. kFactor: string;
  9. fillLevel: number | null; // null = unknown
  10. trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
  11. }
  12. interface SpoolmanConfig {
  13. enabled: boolean;
  14. onLinkSpool?: (trayUuid: string) => void;
  15. hasUnlinkedSpools?: boolean; // Whether there are spools available to link
  16. linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
  17. spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
  18. }
  19. interface ConfigureSlotConfig {
  20. enabled: boolean;
  21. onConfigure?: () => void;
  22. }
  23. interface FilamentHoverCardProps {
  24. data: FilamentData;
  25. children: ReactNode;
  26. disabled?: boolean;
  27. className?: string;
  28. spoolman?: SpoolmanConfig;
  29. configureSlot?: ConfigureSlotConfig;
  30. }
  31. /**
  32. * A hover card that displays filament details when hovering over AMS slots.
  33. * Replaces the basic browser tooltip with a styled popover.
  34. */
  35. export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
  36. const [isVisible, setIsVisible] = useState(false);
  37. const [position, setPosition] = useState<'top' | 'bottom'>('top');
  38. const [copied, setCopied] = useState(false);
  39. const triggerRef = useRef<HTMLDivElement>(null);
  40. const cardRef = useRef<HTMLDivElement>(null);
  41. const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  42. const handleCopyUuid = () => {
  43. const uuid = data.trayUuid;
  44. if (!uuid) return;
  45. // Try modern clipboard API first, fallback to execCommand
  46. if (navigator.clipboard && window.isSecureContext) {
  47. navigator.clipboard.writeText(uuid).then(() => {
  48. setCopied(true);
  49. setTimeout(() => setCopied(false), 2000);
  50. }).catch(() => {
  51. // Fallback on error
  52. fallbackCopy(uuid);
  53. });
  54. } else {
  55. fallbackCopy(uuid);
  56. }
  57. };
  58. const fallbackCopy = (text: string) => {
  59. const textarea = document.createElement('textarea');
  60. textarea.value = text;
  61. textarea.style.position = 'fixed';
  62. textarea.style.opacity = '0';
  63. document.body.appendChild(textarea);
  64. textarea.select();
  65. try {
  66. document.execCommand('copy');
  67. setCopied(true);
  68. setTimeout(() => setCopied(false), 2000);
  69. } catch {
  70. console.error('Failed to copy to clipboard');
  71. }
  72. document.body.removeChild(textarea);
  73. };
  74. // Calculate position when showing
  75. useEffect(() => {
  76. if (isVisible && triggerRef.current && cardRef.current) {
  77. const triggerRect = triggerRef.current.getBoundingClientRect();
  78. const cardHeight = cardRef.current.offsetHeight;
  79. const spaceAbove = triggerRect.top;
  80. const spaceBelow = window.innerHeight - triggerRect.bottom;
  81. // Prefer top, but flip to bottom if not enough space
  82. if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
  83. setPosition('bottom');
  84. } else {
  85. setPosition('top');
  86. }
  87. }
  88. }, [isVisible]);
  89. const handleMouseEnter = () => {
  90. if (disabled) return;
  91. if (timeoutRef.current) clearTimeout(timeoutRef.current);
  92. // Small delay to prevent flicker on quick mouse movements
  93. timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
  94. };
  95. const handleMouseLeave = () => {
  96. if (timeoutRef.current) clearTimeout(timeoutRef.current);
  97. timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
  98. };
  99. // Cleanup timeout on unmount
  100. useEffect(() => {
  101. return () => {
  102. if (timeoutRef.current) clearTimeout(timeoutRef.current);
  103. };
  104. }, []);
  105. // Get fill bar color based on percentage
  106. const getFillColor = (fill: number): string => {
  107. if (fill <= 15) return '#ef4444'; // red
  108. if (fill <= 30) return '#f97316'; // orange
  109. if (fill <= 50) return '#eab308'; // yellow
  110. return '#22c55e'; // green
  111. };
  112. // Determine if color is light (for text contrast on swatch)
  113. const isLightColor = (hex: string | null): boolean => {
  114. if (!hex) return false;
  115. const cleanHex = hex.replace('#', '');
  116. const r = parseInt(cleanHex.slice(0, 2), 16);
  117. const g = parseInt(cleanHex.slice(2, 4), 16);
  118. const b = parseInt(cleanHex.slice(4, 6), 16);
  119. const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  120. return luminance > 0.6;
  121. };
  122. const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
  123. return (
  124. <div
  125. ref={triggerRef}
  126. className={`relative ${className}`}
  127. onMouseEnter={handleMouseEnter}
  128. onMouseLeave={handleMouseLeave}
  129. >
  130. {children}
  131. {/* Hover Card */}
  132. {isVisible && (
  133. <div
  134. ref={cardRef}
  135. className={`
  136. absolute left-1/2 -translate-x-1/2 z-50
  137. ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
  138. animate-in fade-in-0 zoom-in-95 duration-150
  139. `}
  140. style={{
  141. // Ensure card doesn't go off-screen horizontally
  142. maxWidth: 'calc(100vw - 24px)',
  143. }}
  144. >
  145. {/* Card container */}
  146. <div className="
  147. w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary
  148. rounded-lg shadow-xl overflow-hidden
  149. backdrop-blur-sm
  150. ">
  151. {/* Color swatch header - the hero element */}
  152. <div
  153. className="h-12 relative overflow-hidden"
  154. style={{
  155. backgroundColor: colorHex || '#3d3d3d',
  156. }}
  157. >
  158. {/* Subtle gradient overlay for depth */}
  159. <div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent" />
  160. {/* Color name on swatch */}
  161. <div className={`
  162. absolute inset-0 flex items-center justify-center
  163. font-semibold text-sm tracking-wide
  164. ${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}
  165. `}>
  166. {data.colorName}
  167. </div>
  168. {/* Vendor badge - solid background for visibility on any color */}
  169. <div className={`
  170. absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider
  171. ${data.vendor === 'Bambu Lab'
  172. ? 'bg-black/60 text-white'
  173. : 'bg-black/50 text-white/90'}
  174. `}>
  175. {data.vendor === 'Bambu Lab' ? 'BBL' : 'GEN'}
  176. </div>
  177. </div>
  178. {/* Details section */}
  179. <div className="p-3 space-y-2.5">
  180. {/* Profile name */}
  181. <div className="flex items-center justify-between">
  182. <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
  183. Profile
  184. </span>
  185. <span className="text-xs text-white font-semibold truncate max-w-[120px]">
  186. {data.profile}
  187. </span>
  188. </div>
  189. {/* K Factor */}
  190. <div className="flex items-center justify-between">
  191. <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
  192. K Factor
  193. </span>
  194. <span className="text-xs text-bambu-green font-mono font-bold">
  195. {data.kFactor}
  196. </span>
  197. </div>
  198. {/* Fill Level */}
  199. <div className="space-y-1">
  200. <div className="flex items-center justify-between">
  201. <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
  202. <Droplets className="w-3 h-3" />
  203. Fill
  204. </span>
  205. <span className="text-xs text-white font-semibold">
  206. {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
  207. </span>
  208. </div>
  209. {/* Fill bar */}
  210. <div className="h-1.5 bg-black/40 rounded-full overflow-hidden">
  211. {data.fillLevel !== null ? (
  212. <div
  213. className="h-full rounded-full transition-all duration-300"
  214. style={{
  215. width: `${data.fillLevel}%`,
  216. backgroundColor: getFillColor(data.fillLevel),
  217. }}
  218. />
  219. ) : (
  220. <div className="h-full w-full bg-bambu-gray/30 rounded-full" />
  221. )}
  222. </div>
  223. </div>
  224. {/* Spoolman section - only show if enabled */}
  225. {spoolman?.enabled && data.trayUuid && (
  226. <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
  227. {/* Tray UUID with copy button */}
  228. <div className="flex items-center justify-between">
  229. <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
  230. Spool ID
  231. </span>
  232. <button
  233. onClick={(e) => {
  234. e.stopPropagation();
  235. handleCopyUuid();
  236. }}
  237. className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
  238. title="Copy spool UUID"
  239. >
  240. <span className="font-mono text-[10px] truncate max-w-[80px]">
  241. {data.trayUuid.slice(0, 8)}...
  242. </span>
  243. {copied ? (
  244. <Check className="w-3 h-3 text-bambu-green" />
  245. ) : (
  246. <Copy className="w-3 h-3" />
  247. )}
  248. </button>
  249. </div>
  250. {/* Open in Spoolman button (when already linked) */}
  251. {spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
  252. <a
  253. href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
  254. target="_blank"
  255. rel="noopener noreferrer"
  256. onClick={(e) => e.stopPropagation()}
  257. className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
  258. title="View this spool in Spoolman"
  259. >
  260. <ExternalLink className="w-3.5 h-3.5" />
  261. Open in Spoolman
  262. </a>
  263. )}
  264. {/* Link Spool button (when not linked) */}
  265. {!spoolman.linkedSpoolId && spoolman.onLinkSpool && (
  266. <button
  267. onClick={(e) => {
  268. e.stopPropagation();
  269. if (spoolman.hasUnlinkedSpools !== false) {
  270. spoolman.onLinkSpool?.(data.trayUuid!);
  271. }
  272. }}
  273. disabled={spoolman.hasUnlinkedSpools === false}
  274. className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors ${
  275. spoolman.hasUnlinkedSpools === false
  276. ? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
  277. : 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
  278. }`}
  279. title={spoolman.hasUnlinkedSpools === false ? 'No unlinked spools available' : 'Link this spool to a Spoolman spool'}
  280. >
  281. <Link2 className="w-3.5 h-3.5" />
  282. Link to Spoolman
  283. </button>
  284. )}
  285. </div>
  286. )}
  287. {/* Configure slot section - always show if enabled */}
  288. {configureSlot?.enabled && (
  289. <div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>
  290. <button
  291. onClick={(e) => {
  292. e.stopPropagation();
  293. configureSlot.onConfigure?.();
  294. }}
  295. className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
  296. title="Configure slot with filament profile and K value"
  297. >
  298. <Settings2 className="w-3.5 h-3.5" />
  299. Configure
  300. </button>
  301. </div>
  302. )}
  303. </div>
  304. </div>
  305. {/* Arrow pointer */}
  306. <div
  307. className={`
  308. absolute left-1/2 -translate-x-1/2 w-0 h-0
  309. border-l-[6px] border-l-transparent
  310. border-r-[6px] border-r-transparent
  311. ${position === 'top'
  312. ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'
  313. : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}
  314. `}
  315. />
  316. </div>
  317. )}
  318. </div>
  319. );
  320. }
  321. interface EmptySlotHoverCardProps {
  322. children: ReactNode;
  323. className?: string;
  324. configureSlot?: ConfigureSlotConfig;
  325. }
  326. /**
  327. * Wrapper for empty slots - shows "Empty" on hover with optional configure button
  328. */
  329. export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
  330. const [isVisible, setIsVisible] = useState(false);
  331. const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  332. const handleMouseEnter = () => {
  333. if (timeoutRef.current) clearTimeout(timeoutRef.current);
  334. timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
  335. };
  336. const handleMouseLeave = () => {
  337. if (timeoutRef.current) clearTimeout(timeoutRef.current);
  338. timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
  339. };
  340. useEffect(() => {
  341. return () => {
  342. if (timeoutRef.current) clearTimeout(timeoutRef.current);
  343. };
  344. }, []);
  345. return (
  346. <div
  347. className={`relative ${className}`}
  348. onMouseEnter={handleMouseEnter}
  349. onMouseLeave={handleMouseLeave}
  350. >
  351. {children}
  352. {isVisible && (
  353. <div className="
  354. absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50
  355. animate-in fade-in-0 zoom-in-95 duration-150
  356. ">
  357. <div className="
  358. bg-bambu-dark-secondary border border-bambu-dark-tertiary
  359. rounded-md shadow-lg overflow-hidden
  360. ">
  361. <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
  362. Empty slot
  363. </div>
  364. {/* Configure slot button */}
  365. {configureSlot?.enabled && (
  366. <div className="px-2 pb-2">
  367. <button
  368. onClick={(e) => {
  369. e.stopPropagation();
  370. configureSlot.onConfigure?.();
  371. }}
  372. className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
  373. title="Configure slot with filament profile and K value"
  374. >
  375. <Settings2 className="w-3.5 h-3.5" />
  376. Configure
  377. </button>
  378. </div>
  379. )}
  380. </div>
  381. <div className="
  382. absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
  383. border-l-[5px] border-l-transparent
  384. border-r-[5px] border-r-transparent
  385. border-t-[5px] border-t-bambu-dark-tertiary
  386. " />
  387. </div>
  388. )}
  389. </div>
  390. );
  391. }