@omniradiology/omnirad 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
@@ -0,0 +1,541 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { createPortal } from "react-dom"
5
+ import {
6
+ ZoomIn, ZoomOut, RotateCcw, RotateCw,
7
+ Sun, Contrast, Ruler, Expand,
8
+ ChevronLeft, ChevronRight, X,
9
+ FlipHorizontal, FlipVertical,
10
+ Maximize2, RefreshCw, SlidersHorizontal
11
+ } from "lucide-react"
12
+
13
+ interface ImageViewerProps {
14
+ imageSrc?: string | null;
15
+ className?: string;
16
+ isCollapsed: boolean;
17
+ onToggleCollapse: () => void;
18
+ images?: string[];
19
+ forceFullscreen?: boolean;
20
+ onCloseFullscreen?: () => void;
21
+ }
22
+
23
+ interface MeasurementPoint { x: number; y: number; }
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // MINIMAL EMBEDDED TOOLBAR (pill style, used in side panel & full report)
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ const Sep = () => <div className="w-px h-4 bg-white/15 shrink-0" />;
29
+
30
+ function PillBtn({ onClick, title, active = false, children }: {
31
+ onClick: () => void; title: string; active?: boolean; children: React.ReactNode;
32
+ }) {
33
+ return (
34
+ <button onClick={onClick} title={title}
35
+ className={`flex items-center justify-center w-7 h-7 rounded-md transition-all duration-150
36
+ ${active ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'}`}>
37
+ {children}
38
+ </button>
39
+ );
40
+ }
41
+
42
+ function MinimalToolbar({
43
+ currentImageIndex, imagesCount, onPrev, onNext,
44
+ onZoomIn, onZoomOut, onRotateLeft, onRotateRight,
45
+ measurementMode, onToggleMeasure, onReset,
46
+ onOpenFullscreen,
47
+ showSliders, onToggleSliders,
48
+ brightness, onBrightness, contrast, onContrast,
49
+ }: {
50
+ onZoomIn: () => void; onZoomOut: () => void;
51
+ currentImageIndex: number; imagesCount: number; onPrev: () => void; onNext: () => void;
52
+ onRotateLeft: () => void; onRotateRight: () => void;
53
+ measurementMode: boolean; onToggleMeasure: () => void;
54
+ onReset: () => void; onOpenFullscreen: () => void;
55
+ showSliders: boolean; onToggleSliders: () => void;
56
+ brightness: number; onBrightness: (v: number) => void;
57
+ contrast: number; onContrast: (v: number) => void;
58
+ }) {
59
+ return (
60
+ <div className="relative">
61
+ <div className="flex items-center gap-1 px-2 py-1.5 rounded-xl bg-black/60 border border-white/10 backdrop-blur-md shadow-2xl">
62
+ {/* Zoom */}
63
+ <PillBtn onClick={onZoomIn} title="Zoom In"><ZoomIn className="w-3.5 h-3.5" /></PillBtn>
64
+ <PillBtn onClick={onZoomOut} title="Zoom Out"><ZoomOut className="w-3.5 h-3.5" /></PillBtn>
65
+
66
+ {/* Navigation */}
67
+ {imagesCount > 1 && (<>
68
+ <Sep />
69
+ <PillBtn onClick={onPrev} title="Previous"><ChevronLeft className="w-3.5 h-3.5" /></PillBtn>
70
+ <span className="text-[11px] font-medium text-white/80 tabular-nums min-w-[2.2rem] text-center select-none">
71
+ {currentImageIndex + 1}/{imagesCount}
72
+ </span>
73
+ <PillBtn onClick={onNext} title="Next"><ChevronRight className="w-3.5 h-3.5" /></PillBtn>
74
+ </>)}
75
+
76
+ <Sep />
77
+
78
+ {/* Rotate */}
79
+ <PillBtn onClick={onRotateLeft} title="Rotate Left"><RotateCcw className="w-3.5 h-3.5" /></PillBtn>
80
+ <PillBtn onClick={onRotateRight} title="Rotate Right"><RotateCw className="w-3.5 h-3.5" /></PillBtn>
81
+
82
+ <Sep />
83
+
84
+ {/* Measure + Reset */}
85
+ <PillBtn onClick={onToggleMeasure} title="Measure" active={measurementMode}><Ruler className="w-3.5 h-3.5" /></PillBtn>
86
+ <PillBtn onClick={onReset} title="Reset"><RotateCcw className="w-3.5 h-3.5 opacity-60" /></PillBtn>
87
+
88
+ <Sep />
89
+
90
+ {/* Sliders toggle */}
91
+ <PillBtn onClick={onToggleSliders} title="Brightness & Contrast" active={showSliders}>
92
+ <SlidersHorizontal className="w-3.5 h-3.5" />
93
+ </PillBtn>
94
+
95
+ {/* Open Fullscreen DICOM viewer */}
96
+ <PillBtn onClick={onOpenFullscreen} title="Open Fullscreen Viewer">
97
+ <Expand className="w-3.5 h-3.5" />
98
+ </PillBtn>
99
+ </div>
100
+
101
+ {/* Sliders dropdown */}
102
+ {showSliders && (
103
+ <div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 z-30 bg-black/70 border border-white/10 backdrop-blur-md rounded-xl p-3 shadow-2xl flex flex-col gap-3 min-w-[190px]">
104
+ <div className="flex items-center gap-2">
105
+ <Sun className="w-3.5 h-3.5 text-white/50 shrink-0" />
106
+ <input type="range" min="50" max="200" value={brightness}
107
+ onChange={e => onBrightness(Number(e.target.value))}
108
+ className="flex-1 h-1 appearance-none bg-white/20 rounded-full cursor-pointer" style={{ accentColor: '#60a5fa' }} />
109
+ <span className="text-[10px] text-white/40 w-6 text-right tabular-nums">{brightness}</span>
110
+ </div>
111
+ <div className="flex items-center gap-2">
112
+ <Contrast className="w-3.5 h-3.5 text-white/50 shrink-0" />
113
+ <input type="range" min="50" max="200" value={contrast}
114
+ onChange={e => onContrast(Number(e.target.value))}
115
+ className="flex-1 h-1 appearance-none bg-white/20 rounded-full cursor-pointer" style={{ accentColor: '#60a5fa' }} />
116
+ <span className="text-[10px] text-white/40 w-6 text-right tabular-nums">{contrast}</span>
117
+ </div>
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // ─────────────────────────────────────────────────────────────────────────────
125
+ // FULL DICOM TOOLBAR (fullscreen portal only)
126
+ // ─────────────────────────────────────────────────────────────────────────────
127
+ function DicomIconBtn({ onClick, title, active = false, label, children }: {
128
+ onClick: () => void; title: string; active?: boolean; label?: string; children: React.ReactNode;
129
+ }) {
130
+ return (
131
+ <button onClick={onClick} title={title}
132
+ className={`flex flex-col items-center justify-center gap-0.5 px-2 py-1.5 rounded-lg transition-all min-w-[2.5rem]
133
+ ${active ? 'bg-blue-600/80 text-white' : 'text-white/55 hover:text-white hover:bg-white/10'}`}>
134
+ <span className="w-4 h-4 flex items-center justify-center">{children}</span>
135
+ {label && <span className="text-[8px] font-medium uppercase tracking-wide opacity-75">{label}</span>}
136
+ </button>
137
+ );
138
+ }
139
+
140
+ const DicomSep = () => <div className="w-px h-9 bg-white/10 shrink-0 mx-0.5" />;
141
+
142
+ function DicomSlider({ icon, label, value, onChange, min = 50, max = 200 }: {
143
+ icon: React.ReactNode; label: string;
144
+ value: number; onChange: (v: number) => void; min?: number; max?: number;
145
+ }) {
146
+ return (
147
+ <div className="flex flex-col gap-0.5 items-center min-w-[76px]">
148
+ <div className="flex items-center gap-1 text-white/45">
149
+ <span className="w-3 h-3">{icon}</span>
150
+ <span className="text-[8px] uppercase tracking-wide font-medium">{label}</span>
151
+ <span className="text-[8px] text-white/35 tabular-nums">{value}</span>
152
+ </div>
153
+ <input type="range" min={min} max={max} value={value}
154
+ onChange={e => onChange(Number(e.target.value))}
155
+ className="w-full h-1 rounded-full appearance-none cursor-pointer bg-white/15"
156
+ style={{ accentColor: '#60a5fa' }} />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ function DicomToolbar({
162
+ currentImageIndex, imagesCount, onPrev, onNext,
163
+ onZoomIn, onZoomOut, scale,
164
+ onRotateLeft, onRotateRight, onFlipH, onFlipV,
165
+ measurementMode, onToggleMeasure, onReset,
166
+ onClose,
167
+ brightness, onBrightness, contrast, onContrast,
168
+ }: {
169
+ onZoomIn: () => void; onZoomOut: () => void; scale: number;
170
+ currentImageIndex: number; imagesCount: number; onPrev: () => void; onNext: () => void;
171
+ onRotateLeft: () => void; onRotateRight: () => void; onFlipH: () => void; onFlipV: () => void;
172
+ measurementMode: boolean; onToggleMeasure: () => void; onReset: () => void;
173
+ onClose: () => void;
174
+ brightness: number; onBrightness: (v: number) => void;
175
+ contrast: number; onContrast: (v: number) => void;
176
+ }) {
177
+ return (
178
+ <div className="shrink-0 flex items-center gap-0.5 px-2 py-1 bg-[#111318] border-b border-white/8 overflow-x-auto scrollbar-none">
179
+
180
+ {/* Navigate */}
181
+ {imagesCount > 1 && (<>
182
+ <DicomIconBtn onClick={onPrev} title="Previous Frame" label="Prev"><ChevronLeft className="w-4 h-4" /></DicomIconBtn>
183
+ <div className="flex flex-col items-center px-2">
184
+ <span className="text-white font-bold text-sm tabular-nums leading-none">
185
+ {currentImageIndex + 1}<span className="text-white/30">/{imagesCount}</span>
186
+ </span>
187
+ <span className="text-[8px] text-white/30 uppercase tracking-wider mt-0.5">Frame</span>
188
+ </div>
189
+ <DicomIconBtn onClick={onNext} title="Next Frame" label="Next"><ChevronRight className="w-4 h-4" /></DicomIconBtn>
190
+ <DicomSep />
191
+ </>)}
192
+
193
+ {/* Zoom */}
194
+ <DicomIconBtn onClick={onZoomIn} title="Zoom In" label="In"><ZoomIn className="w-4 h-4" /></DicomIconBtn>
195
+ <div className="flex flex-col items-center px-1.5">
196
+ <span className="text-white/70 font-semibold text-[11px] tabular-nums leading-none">{Math.round(scale * 100)}%</span>
197
+ <span className="text-[8px] text-white/30 uppercase tracking-wider mt-0.5">Zoom</span>
198
+ </div>
199
+ <DicomIconBtn onClick={onZoomOut} title="Zoom Out" label="Out"><ZoomOut className="w-4 h-4" /></DicomIconBtn>
200
+
201
+ <DicomSep />
202
+
203
+ {/* Rotate & Flip */}
204
+ <DicomIconBtn onClick={onRotateLeft} title="Rotate Left 90°" label="L-Rot"><RotateCcw className="w-4 h-4" /></DicomIconBtn>
205
+ <DicomIconBtn onClick={onRotateRight} title="Rotate Right 90°" label="R-Rot"><RotateCw className="w-4 h-4" /></DicomIconBtn>
206
+ <DicomIconBtn onClick={onFlipH} title="Flip Horizontal" label="Flip H"><FlipHorizontal className="w-4 h-4" /></DicomIconBtn>
207
+ <DicomIconBtn onClick={onFlipV} title="Flip Vertical" label="Flip V"><FlipVertical className="w-4 h-4" /></DicomIconBtn>
208
+
209
+ <DicomSep />
210
+
211
+ {/* Window / Level */}
212
+ <div className="flex items-center gap-3 px-2">
213
+ <DicomSlider icon={<Sun className="w-3 h-3" />} label="Bright" value={brightness} onChange={onBrightness} />
214
+ <DicomSlider icon={<Contrast className="w-3 h-3" />} label="Contrast" value={contrast} onChange={onContrast} />
215
+ </div>
216
+
217
+ <DicomSep />
218
+
219
+ {/* Tools */}
220
+ <DicomIconBtn onClick={onToggleMeasure} title="Measure Distance" label="Measure" active={measurementMode}><Ruler className="w-4 h-4" /></DicomIconBtn>
221
+ <DicomIconBtn onClick={onReset} title="Reset All" label="Reset"><RefreshCw className="w-4 h-4" /></DicomIconBtn>
222
+
223
+ <DicomSep />
224
+
225
+ {/* Close fullscreen */}
226
+ <DicomIconBtn onClick={onClose} title="Exit Fullscreen (Esc)" label="Close"><X className="w-4 h-4" /></DicomIconBtn>
227
+ </div>
228
+ );
229
+ }
230
+
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+ // MAIN COMPONENT
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+ export function ImageViewer({ imageSrc, className, isCollapsed, images = [], forceFullscreen, onCloseFullscreen }: ImageViewerProps) {
235
+ const [scale, setScale] = React.useState(1);
236
+ const [position, setPosition] = React.useState({ x: 0, y: 0 });
237
+ const [brightness, setBrightness] = React.useState(100);
238
+ const [contrast, setContrast] = React.useState(100);
239
+ const [rotation, setRotation] = React.useState(0);
240
+ const [flipH, setFlipH] = React.useState(false);
241
+ const [flipV, setFlipV] = React.useState(false);
242
+ const [isDragging, setIsDragging] = React.useState(false);
243
+ const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 });
244
+ const [measurementMode, setMeasurementMode] = React.useState(false);
245
+ const [measurementPoints, setMeasurementPoints] = React.useState<MeasurementPoint[]>([]);
246
+ const [currentImageIndex, setCurrentImageIndex] = React.useState(0);
247
+ const [isFullscreen, setIsFullscreen] = React.useState(forceFullscreen || false);
248
+ const [showSliders, setShowSliders] = React.useState(false);
249
+ const [mounted, setMounted] = React.useState(false);
250
+
251
+ const containerRef = React.useRef<HTMLDivElement>(null);
252
+ const imageRef = React.useRef<HTMLImageElement>(null);
253
+
254
+ React.useEffect(() => { setMounted(true); }, []);
255
+
256
+ const activeImage = images.length > 0 ? images[currentImageIndex] : imageSrc;
257
+
258
+ const handleZoomIn = () => setScale(s => Math.min(s + 0.25, 6));
259
+ const handleZoomOut = () => setScale(s => Math.max(s - 0.25, 0.25));
260
+ const handleRotateLeft = () => setRotation(r => (r - 90 + 360) % 360);
261
+ const handleRotateRight = () => setRotation(r => (r + 90) % 360);
262
+ const handleFlipH = () => setFlipH(f => !f);
263
+ const handleFlipV = () => setFlipV(f => !f);
264
+
265
+ const handleReset = () => {
266
+ setScale(1); setPosition({ x: 0, y: 0 });
267
+ setBrightness(100); setContrast(100);
268
+ setRotation(0); setFlipH(false); setFlipV(false);
269
+ setMeasurementPoints([]); setMeasurementMode(false);
270
+ };
271
+
272
+ const handleNextImage = () => {
273
+ if (images.length > 1) { setCurrentImageIndex(p => (p + 1) % images.length); handleReset(); }
274
+ };
275
+ const handlePrevImage = () => {
276
+ if (images.length > 1) { setCurrentImageIndex(p => (p - 1 + images.length) % images.length); handleReset(); }
277
+ };
278
+
279
+ React.useEffect(() => {
280
+ if (!isFullscreen) return;
281
+ const onKey = (e: KeyboardEvent) => {
282
+ if (e.key === 'Escape') setIsFullscreen(false);
283
+ if (e.key === 'ArrowRight') handleNextImage();
284
+ if (e.key === 'ArrowLeft') handlePrevImage();
285
+ if (e.key === '+') handleZoomIn();
286
+ if (e.key === '-') handleZoomOut();
287
+ };
288
+ window.addEventListener('keydown', onKey);
289
+ return () => window.removeEventListener('keydown', onKey);
290
+ }, [isFullscreen, images.length, currentImageIndex]);
291
+
292
+ const handleWheel = (e: React.WheelEvent) => {
293
+ e.preventDefault();
294
+ e.deltaY < 0 ? handleZoomIn() : handleZoomOut();
295
+ };
296
+
297
+ const handleMouseDown = (e: React.MouseEvent) => {
298
+ if (measurementMode) {
299
+ const rect = imageRef.current?.getBoundingClientRect();
300
+ if (rect) {
301
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
302
+ if (measurementPoints.length < 2) setMeasurementPoints(prev => [...prev, { x, y }]);
303
+ else setMeasurementPoints([{ x, y }]);
304
+ }
305
+ } else {
306
+ setIsDragging(true);
307
+ setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
308
+ }
309
+ };
310
+ const handleMouseMove = (e: React.MouseEvent) => {
311
+ if (isDragging && !measurementMode) setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
312
+ };
313
+ const handleMouseUp = () => setIsDragging(false);
314
+ const toggleMeasure = () => { setMeasurementMode(m => !m); setMeasurementPoints([]); };
315
+
316
+ const distance = (() => {
317
+ if (measurementPoints.length === 2) {
318
+ const dx = measurementPoints[1].x - measurementPoints[0].x;
319
+ const dy = measurementPoints[1].y - measurementPoints[0].y;
320
+ return Math.sqrt(dx * dx + dy * dy).toFixed(1);
321
+ }
322
+ return null;
323
+ })();
324
+
325
+ const transform = `translate(${position.x}px,${position.y}px) scale(${scale}) rotate(${rotation}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`;
326
+
327
+ const minimalProps = {
328
+ currentImageIndex, imagesCount: images.length,
329
+ onPrev: handlePrevImage, onNext: handleNextImage,
330
+ onZoomIn: handleZoomIn, onZoomOut: handleZoomOut,
331
+ onRotateLeft: handleRotateLeft, onRotateRight: handleRotateRight,
332
+ measurementMode, onToggleMeasure: toggleMeasure,
333
+ onReset: handleReset,
334
+ onOpenFullscreen: () => setIsFullscreen(true),
335
+ showSliders, onToggleSliders: () => setShowSliders(s => !s),
336
+ brightness, onBrightness: setBrightness,
337
+ contrast, onContrast: setContrast,
338
+ };
339
+
340
+ const dicomProps = {
341
+ currentImageIndex, imagesCount: images.length,
342
+ onPrev: handlePrevImage, onNext: handleNextImage,
343
+ onZoomIn: handleZoomIn, onZoomOut: handleZoomOut, scale,
344
+ onRotateLeft: handleRotateLeft, onRotateRight: handleRotateRight,
345
+ onFlipH: handleFlipH, onFlipV: handleFlipV,
346
+ measurementMode, onToggleMeasure: toggleMeasure,
347
+ onReset: handleReset,
348
+ onClose: () => {
349
+ if (onCloseFullscreen) onCloseFullscreen();
350
+ else setIsFullscreen(false);
351
+ },
352
+ brightness, onBrightness: setBrightness,
353
+ contrast, onContrast: setContrast,
354
+ };
355
+
356
+ // Shared image canvas
357
+ const makeCanvas = (extraClass = "") => (
358
+ <div ref={containerRef}
359
+ className={`flex-1 flex items-center justify-center overflow-hidden ${measurementMode ? 'cursor-crosshair' : 'cursor-move'} ${isCollapsed ? 'opacity-40 pointer-events-none' : ''} ${extraClass}`}
360
+ onMouseDown={handleMouseDown} onMouseMove={handleMouseMove}
361
+ onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}>
362
+ {activeImage ? (
363
+ <div className="relative select-none">
364
+ <img ref={imageRef} src={activeImage} alt="Radiology Scan"
365
+ className="max-w-none max-h-none block"
366
+ style={{ transform, filter: `brightness(${brightness}%) contrast(${contrast}%)`, transition: isDragging ? 'none' : 'transform 0.05s' }}
367
+ draggable={false} />
368
+ {/* Measurement SVG */}
369
+ {measurementMode && measurementPoints.length > 0 && (
370
+ <svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible"
371
+ style={{ transform: `translate(${position.x}px,${position.y}px) scale(${scale})` }}>
372
+ {measurementPoints.length === 2 && (
373
+ <>
374
+ <line x1={measurementPoints[0].x} y1={measurementPoints[0].y}
375
+ x2={measurementPoints[1].x} y2={measurementPoints[1].y}
376
+ stroke="#3b82f6" strokeWidth="1.5" strokeDasharray="5 3" />
377
+ {distance && (
378
+ <text x={(measurementPoints[0].x + measurementPoints[1].x) / 2}
379
+ y={(measurementPoints[0].y + measurementPoints[1].y) / 2 - 8}
380
+ fill="white" fontSize="11" textAnchor="middle"
381
+ style={{ filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.9))' }}>
382
+ {distance}px
383
+ </text>
384
+ )}
385
+ </>
386
+ )}
387
+ <circle cx={measurementPoints[0].x} cy={measurementPoints[0].y} r="4" fill="#3b82f6" fillOpacity="0.9" />
388
+ {measurementPoints.length === 2 && <circle cx={measurementPoints[1].x} cy={measurementPoints[1].y} r="4" fill="#3b82f6" fillOpacity="0.9" />}
389
+ </svg>
390
+ )}
391
+ </div>
392
+ ) : (
393
+ <p className="text-white/20 text-sm">No image loaded</p>
394
+ )}
395
+ </div>
396
+ );
397
+
398
+ // ── Cine loop (play/pause for multi-image) ──
399
+ const [isPlaying, setIsPlaying] = React.useState(false);
400
+ const [fps, setFps] = React.useState(8);
401
+ const playIntervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
402
+
403
+ React.useEffect(() => {
404
+ if (isPlaying && images.length > 1) {
405
+ playIntervalRef.current = setInterval(() => {
406
+ setCurrentImageIndex(prev => {
407
+ const next = prev + 1;
408
+ return next >= images.length ? 0 : next;
409
+ });
410
+ }, 1000 / fps);
411
+ } else {
412
+ if (playIntervalRef.current) {
413
+ clearInterval(playIntervalRef.current);
414
+ playIntervalRef.current = null;
415
+ }
416
+ }
417
+ return () => {
418
+ if (playIntervalRef.current) {
419
+ clearInterval(playIntervalRef.current);
420
+ playIntervalRef.current = null;
421
+ }
422
+ };
423
+ }, [isPlaying, fps, images.length]);
424
+
425
+ // ── Slice navigation bar (OHIF-style) — shared between embedded and fullscreen ──
426
+ const sliceNavBar = images.length > 1 && (
427
+ <div className="shrink-0 bg-[#0d1117] border-t border-white/10 px-3 py-2 flex flex-col gap-1.5">
428
+ {/* Main row: play + slider + counter */}
429
+ <div className="flex items-center gap-2">
430
+ {/* Play/Pause */}
431
+ <button
432
+ onClick={() => setIsPlaying(p => !p)}
433
+ className={`w-7 h-7 flex items-center justify-center rounded transition-all text-sm shrink-0
434
+ ${isPlaying
435
+ ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
436
+ : 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'
437
+ }`}
438
+ title={isPlaying ? 'Pause' : 'Play cine loop'}
439
+ >
440
+ {isPlaying ? '⏸' : '▶'}
441
+ </button>
442
+
443
+ {/* Scrub slider */}
444
+ <input
445
+ type="range"
446
+ min={0}
447
+ max={images.length - 1}
448
+ value={currentImageIndex}
449
+ onChange={e => { setCurrentImageIndex(parseInt(e.target.value, 10)); setMeasurementPoints([]); }}
450
+ className="flex-1 h-1.5 appearance-none bg-white/10 rounded-full cursor-pointer
451
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5
452
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:shadow-lg
453
+ [&::-webkit-slider-thumb]:shadow-blue-500/40 [&::-webkit-slider-thumb]:cursor-grab
454
+ [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5
455
+ [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:border-0
456
+ [&::-moz-range-thumb]:cursor-grab"
457
+ style={{
458
+ background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(currentImageIndex / (images.length - 1)) * 100}%, rgba(255,255,255,0.1) ${(currentImageIndex / (images.length - 1)) * 100}%, rgba(255,255,255,0.1) 100%)`
459
+ }}
460
+ />
461
+
462
+ {/* Slice counter */}
463
+ <span className="text-[11px] text-white/70 font-mono tabular-nums min-w-[5rem] text-right select-none shrink-0">
464
+ {currentImageIndex + 1} / {images.length}
465
+ </span>
466
+ </div>
467
+
468
+ {/* FPS control (only when playing) */}
469
+ {isPlaying && (
470
+ <div className="flex items-center gap-2 pl-9">
471
+ <span className="text-[9px] text-white/40 uppercase tracking-wider">Speed</span>
472
+ <input
473
+ type="range"
474
+ min={1}
475
+ max={30}
476
+ value={fps}
477
+ onChange={e => setFps(parseInt(e.target.value, 10))}
478
+ className="w-20 h-1 appearance-none bg-white/10 rounded-full cursor-pointer
479
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5
480
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white/50
481
+ [&::-moz-range-thumb]:w-2.5 [&::-moz-range-thumb]:h-2.5
482
+ [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white/50 [&::-moz-range-thumb]:border-0"
483
+ />
484
+ <span className="text-[9px] text-white/40 font-mono">{fps} fps</span>
485
+ </div>
486
+ )}
487
+ </div>
488
+ );
489
+
490
+ // Status bar (fullscreen only)
491
+ const statusBar = (
492
+ <div className="shrink-0 flex items-center justify-between px-3 py-0.5 bg-[#0d0f13] border-t border-white/8 text-[9px] text-white/30 font-mono">
493
+ <span>ZOOM {Math.round(scale * 100)}% ROT {rotation}°</span>
494
+ {measurementMode ? (
495
+ <span className="text-blue-400">
496
+ {measurementPoints.length === 0 ? '● Set start point' : measurementPoints.length === 1 ? '● Set end point' : `✓ ${distance} px`}
497
+ </span>
498
+ ) : <span />}
499
+ <span>W:{brightness} L:{contrast}</span>
500
+ </div>
501
+ );
502
+
503
+ // ─── FULLSCREEN PORTAL — full DICOM viewer ────────────────────────────────
504
+ const fullscreenPortal = mounted && isFullscreen ? createPortal(
505
+ <div className="fixed inset-0 z-[9999] bg-[#0a0b0e] flex flex-col" style={{ isolation: 'isolate' }}>
506
+ <DicomToolbar {...dicomProps} />
507
+ {makeCanvas()}
508
+ {sliceNavBar}
509
+ {statusBar}
510
+ </div>,
511
+ document.body
512
+ ) : null;
513
+
514
+ // ─── EMBEDDED — minimal pill toolbar ─────────────────────────────────────
515
+ return (
516
+ <>
517
+ {fullscreenPortal}
518
+ <div className={`relative bg-black overflow-hidden flex flex-col ${className}`}>
519
+ {/* Centered floating pill toolbar */}
520
+ <div className="absolute top-3 left-1/2 -translate-x-1/2 z-20">
521
+ <MinimalToolbar {...minimalProps} />
522
+ </div>
523
+
524
+ {/* Measurement hint overlay */}
525
+ {measurementMode && measurementPoints.length > 0 && (
526
+ <div className="absolute top-16 left-1/2 -translate-x-1/2 z-20 bg-black/60 border border-white/10 backdrop-blur-md px-3 py-1.5 rounded-lg pointer-events-none">
527
+ <p className="text-xs text-white/70">
528
+ {measurementPoints.length === 1 ? 'Click to set endpoint' : `Distance: ${distance} px`}
529
+ </p>
530
+ </div>
531
+ )}
532
+
533
+ {makeCanvas()}
534
+
535
+ {/* Slice nav bar (replaces old thumbnails) */}
536
+ {!isCollapsed && sliceNavBar}
537
+ </div>
538
+ </>
539
+ );
540
+ }
541
+