ModelViewerModal.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import { useState, useEffect } from 'react';
  2. import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
  3. import { ModelViewer } from './ModelViewer';
  4. import { GcodeViewer } from './GcodeViewer';
  5. import { Button } from './Button';
  6. import { api } from '../api/client';
  7. type ViewTab = '3d' | 'gcode';
  8. interface ModelViewerModalProps {
  9. archiveId: number;
  10. title: string;
  11. onClose: () => void;
  12. }
  13. interface Capabilities {
  14. has_model: boolean;
  15. has_gcode: boolean;
  16. build_volume: { x: number; y: number; z: number };
  17. filament_colors: string[];
  18. }
  19. export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
  20. const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
  21. const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
  22. const [loading, setLoading] = useState(true);
  23. // Close on Escape key
  24. useEffect(() => {
  25. const handleKeyDown = (e: KeyboardEvent) => {
  26. if (e.key === 'Escape') onClose();
  27. };
  28. window.addEventListener('keydown', handleKeyDown);
  29. return () => window.removeEventListener('keydown', handleKeyDown);
  30. }, [onClose]);
  31. useEffect(() => {
  32. api.getArchiveCapabilities(archiveId)
  33. .then(caps => {
  34. setCapabilities(caps);
  35. // Auto-select the first available tab
  36. if (caps.has_model) {
  37. setActiveTab('3d');
  38. } else if (caps.has_gcode) {
  39. setActiveTab('gcode');
  40. }
  41. setLoading(false);
  42. })
  43. .catch(() => {
  44. // Fallback to 3D model tab if capabilities check fails
  45. setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
  46. setActiveTab('3d');
  47. setLoading(false);
  48. });
  49. }, [archiveId]);
  50. const handleOpenInSlicer = () => {
  51. // Use bambustudioopen:// protocol like MakerWorld does
  52. // URL must include .3mf filename for Bambu Studio to recognize the format
  53. const filename = title || 'model';
  54. const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
  55. window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
  56. };
  57. return (
  58. <div
  59. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
  60. onClick={onClose}
  61. >
  62. <div
  63. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
  64. onClick={(e) => e.stopPropagation()}
  65. >
  66. {/* Header */}
  67. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  68. <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
  69. <div className="flex items-center gap-2">
  70. <Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
  71. <ExternalLink className="w-4 h-4" />
  72. Open in Slicer
  73. </Button>
  74. <Button variant="ghost" size="sm" onClick={onClose}>
  75. <X className="w-5 h-5" />
  76. </Button>
  77. </div>
  78. </div>
  79. {/* Tabs - only show if we have capabilities */}
  80. {capabilities && (
  81. <div className="flex border-b border-bambu-dark-tertiary">
  82. <button
  83. onClick={() => capabilities.has_model && setActiveTab('3d')}
  84. disabled={!capabilities.has_model}
  85. className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
  86. activeTab === '3d'
  87. ? 'text-bambu-green border-b-2 border-bambu-green'
  88. : capabilities.has_model
  89. ? 'text-bambu-gray hover:text-white'
  90. : 'text-bambu-gray/30 cursor-not-allowed'
  91. }`}
  92. >
  93. <Box className="w-4 h-4" />
  94. 3D Model
  95. {!capabilities.has_model && <span className="text-xs">(not available)</span>}
  96. </button>
  97. <button
  98. onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
  99. disabled={!capabilities.has_gcode}
  100. className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
  101. activeTab === 'gcode'
  102. ? 'text-bambu-green border-b-2 border-bambu-green'
  103. : capabilities.has_gcode
  104. ? 'text-bambu-gray hover:text-white'
  105. : 'text-bambu-gray/30 cursor-not-allowed'
  106. }`}
  107. >
  108. <Code2 className="w-4 h-4" />
  109. G-code Preview
  110. {!capabilities.has_gcode && <span className="text-xs">(not sliced)</span>}
  111. </button>
  112. </div>
  113. )}
  114. {/* Viewer */}
  115. <div className="flex-1 overflow-hidden p-4">
  116. {loading ? (
  117. <div className="w-full h-full flex items-center justify-center">
  118. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  119. </div>
  120. ) : activeTab === '3d' && capabilities ? (
  121. <ModelViewer
  122. url={api.getArchiveDownload(archiveId)}
  123. buildVolume={capabilities.build_volume}
  124. className="w-full h-full"
  125. />
  126. ) : activeTab === 'gcode' && capabilities ? (
  127. <GcodeViewer
  128. gcodeUrl={api.getArchiveGcode(archiveId)}
  129. buildVolume={capabilities.build_volume}
  130. filamentColors={capabilities.filament_colors}
  131. className="w-full h-full"
  132. />
  133. ) : (
  134. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  135. No preview available for this file
  136. </div>
  137. )}
  138. </div>
  139. </div>
  140. </div>
  141. );
  142. }