CameraLauncher.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent } from 'react';
  2. import { createPortal } from 'react-dom';
  3. import { useQuery } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import { Camera, Check, Loader2 } from 'lucide-react';
  6. import { api } from '../api/client';
  7. import { useAuth } from '../contexts/AuthContext';
  8. import { useEmbeddedCameras } from '../contexts/EmbeddedCameraContext';
  9. interface CameraLauncherProps {
  10. /** Where the printer list opens: beside the Printers row of the sidebar, or down from the compact header. */
  11. placement: 'side' | 'down';
  12. /** Smaller on the sidebar's Printers row (xs in the collapsed sidebar); on that row's green when it is the current page. */
  13. size?: 'md' | 'sm' | 'xs';
  14. onAccent?: boolean;
  15. /** Controlled by the layout's keyboard shortcut when given. */
  16. open?: boolean;
  17. onOpenChange?: (open: boolean) => void;
  18. className?: string;
  19. }
  20. const MENU_WIDTH = 256;
  21. const EDGE = 8;
  22. /**
  23. * Where the list goes, in viewport coordinates. It is rendered into
  24. * document.body: inside the sidebar (z-30) or the compact header (z-40) it
  25. * would sit under the camera overlays (z-40, later in the page), and the
  26. * collapsed sidebar's scrolling footer would clip it to 48px.
  27. */
  28. function menuPosition(trigger: DOMRect, placement: 'side' | 'down'): CSSProperties {
  29. const maxLeft = Math.max(EDGE, window.innerWidth - MENU_WIDTH - EDGE);
  30. if (placement === 'down') {
  31. return { top: trigger.bottom + EDGE, left: Math.min(Math.max(EDGE, trigger.right - MENU_WIDTH), maxLeft) };
  32. }
  33. // Out to the right of the sidebar, level with the row.
  34. return { top: Math.max(EDGE, trigger.top - EDGE), left: Math.min(trigger.right + EDGE, maxLeft) };
  35. }
  36. /**
  37. * A printer's camera as an overlay from any page (#2737), without going back
  38. * to the Printers page for its card. Lists the printers the user can see; one
  39. * already open closes again from here.
  40. */
  41. export function CameraLauncher({
  42. placement,
  43. size = 'md',
  44. onAccent = false,
  45. open: openProp,
  46. onOpenChange,
  47. className = '',
  48. }: CameraLauncherProps) {
  49. const { t } = useTranslation();
  50. const { hasPermission } = useAuth();
  51. const { openCameras, openEmbeddedCamera, closeEmbeddedCamera } = useEmbeddedCameras();
  52. const [ownOpen, setOwnOpen] = useState(false);
  53. const open = openProp ?? ownOpen;
  54. const setOpen = useCallback(
  55. (next: boolean) => {
  56. if (openProp === undefined) setOwnOpen(next);
  57. onOpenChange?.(next);
  58. },
  59. [openProp, onOpenChange],
  60. );
  61. const [position, setPosition] = useState<CSSProperties>({});
  62. const rootRef = useRef<HTMLDivElement>(null);
  63. const buttonRef = useRef<HTMLButtonElement>(null);
  64. const menuRef = useRef<HTMLDivElement>(null);
  65. // As the overlays: the printer list takes printers:read.
  66. const canView = hasPermission('camera:view') && hasPermission('printers:read');
  67. const { data: printers, isLoading } = useQuery({
  68. queryKey: ['printers'],
  69. queryFn: api.getPrinters,
  70. enabled: open && canView,
  71. });
  72. useLayoutEffect(() => {
  73. if (!open) return;
  74. const place = () => {
  75. if (buttonRef.current) setPosition(menuPosition(buttonRef.current.getBoundingClientRect(), placement));
  76. };
  77. place();
  78. window.addEventListener('resize', place);
  79. return () => window.removeEventListener('resize', place);
  80. }, [open, placement]);
  81. useEffect(() => {
  82. if (!open) return;
  83. const onPointerDown = (e: PointerEvent) => {
  84. const target = e.target as Node;
  85. if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
  86. };
  87. const onKeyDown = (e: KeyboardEvent) => {
  88. if (e.key === 'Escape') {
  89. setOpen(false);
  90. buttonRef.current?.focus();
  91. }
  92. };
  93. document.addEventListener('pointerdown', onPointerDown);
  94. document.addEventListener('keydown', onKeyDown);
  95. return () => {
  96. document.removeEventListener('pointerdown', onPointerDown);
  97. document.removeEventListener('keydown', onKeyDown);
  98. };
  99. }, [open, setOpen]);
  100. // The list is drawn at the end of the page, so Tab from the button would
  101. // not reach it: focus moves into it, and leaving it closes it.
  102. const hasItems = !!printers && printers.length > 0;
  103. useEffect(() => {
  104. if (open && hasItems) menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
  105. }, [open, hasItems]);
  106. const onMenuBlur = (e: FocusEvent<HTMLDivElement>) => {
  107. const next = e.relatedTarget as Node | null;
  108. if (next && !menuRef.current?.contains(next) && !rootRef.current?.contains(next)) setOpen(false);
  109. };
  110. if (!canView) return null;
  111. const openIds = new Set(openCameras.map(c => c.id));
  112. return (
  113. <div ref={rootRef} className="relative">
  114. <button
  115. ref={buttonRef}
  116. onClick={() => setOpen(!open)}
  117. className={`${{ md: 'p-2 rounded-lg', sm: 'p-1 rounded-md', xs: 'p-0.5 rounded' }[size]} transition-colors ${
  118. onAccent
  119. ? 'text-white hover:bg-white/20'
  120. : `hover:bg-bambu-dark-tertiary ${open || openIds.size > 0 ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'}`
  121. } ${className}`}
  122. title={t('cameraLauncher.title')}
  123. aria-label={t('cameraLauncher.title')}
  124. aria-expanded={open}
  125. >
  126. <Camera className={{ md: 'w-5 h-5', sm: 'w-4 h-4', xs: 'w-3 h-3' }[size]} />
  127. </button>
  128. {open && createPortal(
  129. <div
  130. ref={menuRef}
  131. onBlur={onMenuBlur}
  132. role="region"
  133. aria-label={t('cameraLauncher.title')}
  134. style={position}
  135. className="fixed w-64 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50"
  136. >
  137. <div className="px-4 py-3 border-b border-bambu-dark-tertiary">
  138. <h3 className="text-sm font-semibold text-white flex items-center gap-2">
  139. <Camera className="w-4 h-4 text-bambu-green" />
  140. {t('cameraLauncher.title')}
  141. </h3>
  142. </div>
  143. <div className="p-2 max-h-80 overflow-y-auto">
  144. {isLoading ? (
  145. <div className="flex items-center justify-center py-6">
  146. <Loader2 className="w-5 h-5 text-bambu-gray animate-spin" />
  147. </div>
  148. ) : !printers || printers.length === 0 ? (
  149. <p className="text-sm text-bambu-gray text-center py-4 px-2">{t('cameraLauncher.noPrinters')}</p>
  150. ) : (
  151. printers.map(printer => {
  152. const isOpen = openIds.has(printer.id);
  153. return (
  154. <button
  155. key={printer.id}
  156. aria-pressed={isOpen}
  157. onClick={() => {
  158. if (isOpen) {
  159. closeEmbeddedCamera(printer.id);
  160. } else {
  161. openEmbeddedCamera(printer.id, printer.name, 'app');
  162. setOpen(false);
  163. }
  164. }}
  165. className="w-full flex items-center justify-between gap-2 py-2 px-3 text-left text-sm text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  166. title={isOpen ? t('cameraLauncher.close') : t('cameraLauncher.open')}
  167. >
  168. <span className="truncate">{printer.name}</span>
  169. {isOpen && <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />}
  170. </button>
  171. );
  172. })
  173. )}
  174. </div>
  175. </div>,
  176. document.body,
  177. )}
  178. </div>
  179. );
  180. }