@anymux/ui-kit 0.1.0

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 (244) hide show
  1. package/dist/ExplorerLayout-CSIJd7N4.js +105 -0
  2. package/dist/ExplorerLayout-CSIJd7N4.js.map +1 -0
  3. package/dist/FileBrowserContext-B6jixa2j.js +11 -0
  4. package/dist/FileBrowserContext-B6jixa2j.js.map +1 -0
  5. package/dist/calendar-DSlrbHoj.js +761 -0
  6. package/dist/calendar-DSlrbHoj.js.map +1 -0
  7. package/dist/calendar.d.ts +3 -0
  8. package/dist/calendar.js +3 -0
  9. package/dist/contacts-DQXTZzHc.js +539 -0
  10. package/dist/contacts-DQXTZzHc.js.map +1 -0
  11. package/dist/contacts.d.ts +3 -0
  12. package/dist/contacts.js +3 -0
  13. package/dist/file-browser-m5atC3kF.js +6755 -0
  14. package/dist/file-browser-m5atC3kF.js.map +1 -0
  15. package/dist/file-browser.d.ts +11 -0
  16. package/dist/file-browser.js +9 -0
  17. package/dist/git-B55e6LL-.js +561 -0
  18. package/dist/git-B55e6LL-.js.map +1 -0
  19. package/dist/git.d.ts +2 -0
  20. package/dist/git.js +3 -0
  21. package/dist/iconMap-V4B8P-Uh.js +206 -0
  22. package/dist/iconMap-V4B8P-Uh.js.map +1 -0
  23. package/dist/icons-CIsIOZXR.js +0 -0
  24. package/dist/icons.d.ts +2 -0
  25. package/dist/icons.js +4 -0
  26. package/dist/index-BNmNIWBL.d.ts +71 -0
  27. package/dist/index-BNmNIWBL.d.ts.map +1 -0
  28. package/dist/index-Bryv_GCG.d.ts +1481 -0
  29. package/dist/index-Bryv_GCG.d.ts.map +1 -0
  30. package/dist/index-CuQIjSXs.d.ts +134 -0
  31. package/dist/index-CuQIjSXs.d.ts.map +1 -0
  32. package/dist/index-DSu19mq0.d.ts +153 -0
  33. package/dist/index-DSu19mq0.d.ts.map +1 -0
  34. package/dist/index-DmsyeHFr.d.ts +149 -0
  35. package/dist/index-DmsyeHFr.d.ts.map +1 -0
  36. package/dist/index-DxnJ8FYM.d.ts +17 -0
  37. package/dist/index-DxnJ8FYM.d.ts.map +1 -0
  38. package/dist/index-DzfY1Tok.d.ts +32 -0
  39. package/dist/index-DzfY1Tok.d.ts.map +1 -0
  40. package/dist/index-Ml_SgiKa.d.ts +1847 -0
  41. package/dist/index-Ml_SgiKa.d.ts.map +1 -0
  42. package/dist/index-kHr9udZD.d.ts +1025 -0
  43. package/dist/index-kHr9udZD.d.ts.map +1 -0
  44. package/dist/index.d.ts +11 -0
  45. package/dist/index.js +15 -0
  46. package/dist/layout-Ca_4r8ka.js +89 -0
  47. package/dist/layout-Ca_4r8ka.js.map +1 -0
  48. package/dist/layout.d.ts +2 -0
  49. package/dist/layout.js +5 -0
  50. package/dist/list-CxfT6hix.js +6831 -0
  51. package/dist/list-CxfT6hix.js.map +1 -0
  52. package/dist/list.d.ts +2 -0
  53. package/dist/list.js +5 -0
  54. package/dist/media-DZ292aKK.js +557 -0
  55. package/dist/media-DZ292aKK.js.map +1 -0
  56. package/dist/media.d.ts +3 -0
  57. package/dist/media.js +3 -0
  58. package/dist/tree-Dd9Z0Aso.js +3351 -0
  59. package/dist/tree-Dd9Z0Aso.js.map +1 -0
  60. package/dist/tree.d.ts +2 -0
  61. package/dist/tree.js +6 -0
  62. package/dist/types-common-CB3kRek8.d.ts +26 -0
  63. package/dist/types-common-CB3kRek8.d.ts.map +1 -0
  64. package/dist/utils-B4fdKKsy.js +3 -0
  65. package/package.json +109 -0
  66. package/src/calendar/AgendaView.tsx +37 -0
  67. package/src/calendar/CalendarBrowser.tsx +90 -0
  68. package/src/calendar/CalendarModel.ts +142 -0
  69. package/src/calendar/CalendarSidebar.tsx +81 -0
  70. package/src/calendar/DayView.tsx +76 -0
  71. package/src/calendar/EventCard.tsx +51 -0
  72. package/src/calendar/MockCalendarProvider.ts +98 -0
  73. package/src/calendar/MonthView.tsx +77 -0
  74. package/src/calendar/WeekView.tsx +129 -0
  75. package/src/calendar/index.ts +18 -0
  76. package/src/calendar/types.ts +25 -0
  77. package/src/contacts/ContactAvatar.tsx +35 -0
  78. package/src/contacts/ContactBrowser.tsx +56 -0
  79. package/src/contacts/ContactCard.tsx +37 -0
  80. package/src/contacts/ContactDetail.tsx +63 -0
  81. package/src/contacts/ContactGroupSidebar.tsx +40 -0
  82. package/src/contacts/ContactList.tsx +32 -0
  83. package/src/contacts/ContactListModel.ts +120 -0
  84. package/src/contacts/MockContactProvider.ts +77 -0
  85. package/src/contacts/index.ts +17 -0
  86. package/src/contacts/types.ts +26 -0
  87. package/src/demos/CalendarBrowserDemo.tsx +15 -0
  88. package/src/demos/ContactBrowserDemo.tsx +15 -0
  89. package/src/demos/MediaBrowserDemo.tsx +15 -0
  90. package/src/file-browser/adapters/DocumentViewerAdapter.ts +371 -0
  91. package/src/file-browser/adapters/FileSystemBridge.ts +168 -0
  92. package/src/file-browser/adapters/GitBrowserAdapter.ts +546 -0
  93. package/src/file-browser/adapters/README.md +504 -0
  94. package/src/file-browser/adapters/index.ts +27 -0
  95. package/src/file-browser/adapters/types.ts +70 -0
  96. package/src/file-browser/architecture.md +645 -0
  97. package/src/file-browser/components/CreateItemDialog.tsx +71 -0
  98. package/src/file-browser/components/DeleteConfirmDialog.tsx +58 -0
  99. package/src/file-browser/components/FileBrowser.tsx +473 -0
  100. package/src/file-browser/components/FileBrowserContent.tsx +209 -0
  101. package/src/file-browser/components/FileBrowserHeader.tsx +151 -0
  102. package/src/file-browser/components/FileBrowserToolbar.tsx +145 -0
  103. package/src/file-browser/components/LeftPanel/LeftPanel.tsx +103 -0
  104. package/src/file-browser/components/LeftPanel/LeftPanelTabs.tsx +70 -0
  105. package/src/file-browser/components/LeftPanel/TreeNavigationView.tsx +256 -0
  106. package/src/file-browser/components/PreviewPane.tsx +146 -0
  107. package/src/file-browser/components/RightPanel/FilePreview.tsx +219 -0
  108. package/src/file-browser/components/RightPanel/RightPanel.tsx +186 -0
  109. package/src/file-browser/components/RightPanel/RightPanelToolbar.tsx +113 -0
  110. package/src/file-browser/components/UploadProgress.tsx +123 -0
  111. package/src/file-browser/components/ViewerHost.tsx +208 -0
  112. package/src/file-browser/components/mobile/MobileNavigation.tsx +227 -0
  113. package/src/file-browser/components/navigation/NavigationButtons.tsx +171 -0
  114. package/src/file-browser/components/shared/ErrorBoundary.tsx +116 -0
  115. package/src/file-browser/components/shared/FileBrowserItem.tsx +195 -0
  116. package/src/file-browser/components/shared/FileIcon.tsx +169 -0
  117. package/src/file-browser/components/toolbar/ViewModeToggle.tsx +200 -0
  118. package/src/file-browser/components/views/ListView/ListView.tsx +484 -0
  119. package/src/file-browser/components/views/ThumbnailView/ThumbnailView.tsx +323 -0
  120. package/src/file-browser/components/views/TreeView/TreeNode.tsx +186 -0
  121. package/src/file-browser/components/views/TreeView/TreeNodeList.tsx +191 -0
  122. package/src/file-browser/components/views/TreeView/TreeView.tsx +200 -0
  123. package/src/file-browser/components/views/TreemapView/TreemapView.tsx +339 -0
  124. package/src/file-browser/context/FileBrowserContext.tsx +13 -0
  125. package/src/file-browser/examples/BasicUsage.tsx +20 -0
  126. package/src/file-browser/index.ts +98 -0
  127. package/src/file-browser/models/FileBrowserModel.ts +623 -0
  128. package/src/file-browser/models/LeftPanelManagerModel.ts +105 -0
  129. package/src/file-browser/models/NavigationManagerModel.ts +312 -0
  130. package/src/file-browser/models/ResponsiveLayoutManagerModel.ts +437 -0
  131. package/src/file-browser/models/RightPanelManagerModel.ts +190 -0
  132. package/src/file-browser/models/SelectionManagerModel.ts +252 -0
  133. package/src/file-browser/models/ToolbarManagerModel.ts +144 -0
  134. package/src/file-browser/models/UploadModel.ts +147 -0
  135. package/src/file-browser/models/ViewModeManagerModel.ts +185 -0
  136. package/src/file-browser/models/ViewerHostModel.ts +44 -0
  137. package/src/file-browser/models/ui/ListViewUIModel.ts +265 -0
  138. package/src/file-browser/models/ui/PreviewUIModel.ts +297 -0
  139. package/src/file-browser/models/ui/ThumbnailViewUIModel.ts +254 -0
  140. package/src/file-browser/models/ui/TreeViewUIModel.ts +128 -0
  141. package/src/file-browser/models/ui/TreemapViewUIModel.ts +350 -0
  142. package/src/file-browser/providers/FileSystemListProvider.ts +552 -0
  143. package/src/file-browser/providers/FileSystemProvider.ts +401 -0
  144. package/src/file-browser/providers/FileSystemTreeProvider.ts +231 -0
  145. package/src/file-browser/providers/GitProvider.ts +337 -0
  146. package/src/file-browser/providers/GitRepositoryProvider.ts +376 -0
  147. package/src/file-browser/providers/IFileBrowserProvider.ts +56 -0
  148. package/src/file-browser/providers/MemoryProvider.ts +303 -0
  149. package/src/file-browser/providers/index.ts +4 -0
  150. package/src/file-browser/registry/ViewerRegistry.ts +551 -0
  151. package/src/file-browser/registry/types.ts +144 -0
  152. package/src/file-browser/scripts/performanceBenchmark.ts +553 -0
  153. package/src/file-browser/services/ThumbnailCacheService.ts +128 -0
  154. package/src/file-browser/tasks.md +537 -0
  155. package/src/file-browser/types/FileBrowserTypes.ts +126 -0
  156. package/src/file-browser/types/ProviderTypes.ts +155 -0
  157. package/src/file-browser/types/UITypes.ts +235 -0
  158. package/src/file-browser/types/ViewModeTypes.ts +150 -0
  159. package/src/file-browser/utils/gestures.ts +327 -0
  160. package/src/file-browser/utils/performance.ts +563 -0
  161. package/src/file-browser/viewers/ImageViewer.tsx +163 -0
  162. package/src/file-browser/viewers/ImageViewerModel.ts +79 -0
  163. package/src/file-browser/viewers/TextViewer.tsx +95 -0
  164. package/src/file-browser/viewers/UnsupportedFileViewer.tsx +57 -0
  165. package/src/file-browser/viewers/index.ts +61 -0
  166. package/src/git/BranchList.tsx +128 -0
  167. package/src/git/CommitGraph.tsx +239 -0
  168. package/src/git/CommitList.tsx +258 -0
  169. package/src/git/DiffViewer.tsx +219 -0
  170. package/src/git/index.ts +4 -0
  171. package/src/icons/iconMap.ts +146 -0
  172. package/src/icons/index.ts +9 -0
  173. package/src/index.ts +13 -0
  174. package/src/layout/README.md +307 -0
  175. package/src/layout/components/ExplorerLayout/ExplorerLayout.tsx +178 -0
  176. package/src/layout/examples/SimpleExample.tsx +60 -0
  177. package/src/layout/index.ts +6 -0
  178. package/src/lib/utils.ts +1 -0
  179. package/src/list/README.md +303 -0
  180. package/src/list/architecture.md +807 -0
  181. package/src/list/components/CalculatedGridView.tsx +252 -0
  182. package/src/list/components/DragPreview.tsx +102 -0
  183. package/src/list/components/ListContextMenu.tsx +274 -0
  184. package/src/list/components/ListItem.tsx +761 -0
  185. package/src/list/components/ListItems.tsx +919 -0
  186. package/src/list/components/MasonryView.tsx +241 -0
  187. package/src/list/components/SearchFilter.tsx +44 -0
  188. package/src/list/components/TreemapView.tsx +709 -0
  189. package/src/list/components/ViewSizeControls.tsx +205 -0
  190. package/src/list/components/ViewTypeSelector.tsx +312 -0
  191. package/src/list/components/VirtualizedDetailsView.tsx +231 -0
  192. package/src/list/components/VirtualizedGrid.tsx +164 -0
  193. package/src/list/components/VirtualizedList.tsx +154 -0
  194. package/src/list/components/VirtualizedMasonryView.tsx +344 -0
  195. package/src/list/components/shared/EmptyState.tsx +103 -0
  196. package/src/list/components/shared/ErrorBoundary.tsx +123 -0
  197. package/src/list/components/shared/ErrorDisplay.tsx +100 -0
  198. package/src/list/components/shared/ListLoader.tsx +146 -0
  199. package/src/list/components/shared/LoadingIndicator.tsx +80 -0
  200. package/src/list/index.ts +92 -0
  201. package/src/list/models/ListItemsModel.ts +1301 -0
  202. package/src/list/models/TreemapModel.ts +204 -0
  203. package/src/list/providers/ListItemsProvider.ts +313 -0
  204. package/src/list/providers/TestListProvider.ts +604 -0
  205. package/src/list/tasks.md +937 -0
  206. package/src/list/types/ListTypes.ts +178 -0
  207. package/src/list/utils/BenchmarkLogger.ts +243 -0
  208. package/src/list/utils/DragDropManager.ts +320 -0
  209. package/src/list/utils/GridLayoutCalculator.ts +290 -0
  210. package/src/list/utils/ListAccessibility.ts +367 -0
  211. package/src/list/utils/ListKeyboard.ts +414 -0
  212. package/src/list/utils/MasonryLayoutCalculator.ts +302 -0
  213. package/src/list/utils/MasonryLayoutEngine.ts +401 -0
  214. package/src/list/utils/__tests__/MasonryLayoutEngine.test.ts +157 -0
  215. package/src/list/utils/__tests__/VirtualizedMasonryView.test.tsx +251 -0
  216. package/src/media/AlbumSidebar.tsx +48 -0
  217. package/src/media/MediaBrowser.tsx +92 -0
  218. package/src/media/MediaBrowserModel.ts +138 -0
  219. package/src/media/MediaGrid.tsx +50 -0
  220. package/src/media/MediaList.tsx +49 -0
  221. package/src/media/MediaPreview.tsx +63 -0
  222. package/src/media/MediaTimeline.tsx +38 -0
  223. package/src/media/MockMediaProvider.ts +70 -0
  224. package/src/media/index.ts +18 -0
  225. package/src/media/types.ts +21 -0
  226. package/src/styles/variables.css +60 -0
  227. package/src/tree/DEVELOPMENT_SUMMARY.md +170 -0
  228. package/src/tree/__tests__/TreeModel.test.ts +16 -0
  229. package/src/tree/architecture.md +530 -0
  230. package/src/tree/components/Tree.tsx +283 -0
  231. package/src/tree/components/TreeCheckbox.tsx +147 -0
  232. package/src/tree/components/TreeContextMenu.tsx +139 -0
  233. package/src/tree/components/TreeNodeList.tsx +329 -0
  234. package/src/tree/components/TreeTable.tsx +382 -0
  235. package/src/tree/index.ts +58 -0
  236. package/src/tree/models/TreeModel.ts +839 -0
  237. package/src/tree/providers/SimpleTreeProvider.ts +463 -0
  238. package/src/tree/providers/TestTreeProvider.ts +946 -0
  239. package/src/tree/providers/TreeProvider.ts +308 -0
  240. package/src/tree/tasks.md +2046 -0
  241. package/src/tree/types/TreeTypes.ts +279 -0
  242. package/src/tree/utils/SelectionTheme.ts +150 -0
  243. package/src/tree/utils/logger.ts +203 -0
  244. package/src/types-common.ts +21 -0
@@ -0,0 +1,251 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { VirtualizedMasonryView } from '../../components/VirtualizedMasonryView';
5
+ import { ListItemsModel } from '../../models/ListItemsModel';
6
+ import type { ListItemData } from '../../types/ListTypes';
7
+
8
+ // Mock lucide-react icons
9
+ jest.mock('lucide-react', () => ({
10
+ FileText: () => <div data-testid="file-text-icon">FileText</div>,
11
+ Image: () => <div data-testid="image-icon">Image</div>,
12
+ Video: () => <div data-testid="video-icon">Video</div>,
13
+ Music: () => <div data-testid="music-icon">Music</div>,
14
+ Folder: () => <div data-testid="folder-icon">Folder</div>,
15
+ Archive: () => <div data-testid="archive-icon">Archive</div>,
16
+ File: () => <div data-testid="file-icon">File</div>,
17
+ Settings: () => <div data-testid="settings-icon">Settings</div>,
18
+ Code: () => <div data-testid="code-icon">Code</div>,
19
+ Database: () => <div data-testid="database-icon">Database</div>,
20
+ Globe: () => <div data-testid="globe-icon">Globe</div>,
21
+ Palette: () => <div data-testid="palette-icon">Palette</div>,
22
+ Package: () => <div data-testid="package-icon">Package</div>,
23
+ BookOpen: () => <div data-testid="book-open-icon">BookOpen</div>,
24
+ TestTube: () => <div data-testid="test-tube-icon">TestTube</div>,
25
+ GitBranch: () => <div data-testid="git-branch-icon">GitBranch</div>,
26
+ Key: () => <div data-testid="key-icon">Key</div>,
27
+ }));
28
+
29
+ // Create a minimal test provider without lucide-react dependencies
30
+ class MinimalTestProvider {
31
+ id = 'test';
32
+ name = 'Test Provider';
33
+ supportedViewTypes = ['grid', 'masonry-vertical', 'masonry-horizontal'];
34
+ isVirtualizationEnabled = true;
35
+
36
+ async loadItems() {
37
+ return { items: [], totalCount: 0 };
38
+ }
39
+
40
+ getItemIcon() {
41
+ return null;
42
+ }
43
+
44
+ getItemContextMenu() {
45
+ return [];
46
+ }
47
+
48
+ onSelectionChange() {}
49
+ onItemAction() {}
50
+ onContextMenuAction() {}
51
+ }
52
+
53
+ // Mock the masonry layout engine
54
+ jest.mock('../MasonryLayoutEngine', () => ({
55
+ createVerticalMasonry: jest.fn(() => ({
56
+ calculateLayout: jest.fn(() => ({
57
+ items: [
58
+ { id: '1', x: 0, y: 0, width: 200, height: 300 },
59
+ { id: '2', x: 220, y: 0, width: 200, height: 150 },
60
+ { id: '3', x: 0, y: 320, width: 200, height: 200 },
61
+ { id: '4', x: 220, y: 170, width: 200, height: 400 },
62
+ { id: '5', x: 0, y: 540, width: 200, height: 100 }
63
+ ],
64
+ totalHeight: 640,
65
+ totalWidth: 440,
66
+ columns: 2
67
+ }))
68
+ })),
69
+ createHorizontalMasonry: jest.fn(() => ({
70
+ calculateLayout: jest.fn(() => ({
71
+ items: [
72
+ { id: '1', x: 0, y: 0, width: 200, height: 300 },
73
+ { id: '2', x: 220, y: 0, width: 200, height: 150 },
74
+ { id: '3', x: 0, y: 320, width: 200, height: 200 },
75
+ { id: '4', x: 220, y: 170, width: 200, height: 400 },
76
+ { id: '5', x: 0, y: 540, width: 200, height: 100 }
77
+ ],
78
+ totalHeight: 640,
79
+ totalWidth: 440,
80
+ columns: 2
81
+ }))
82
+ }))
83
+ }));
84
+
85
+ describe.skip('VirtualizedMasonryView', () => {
86
+ let model: ListItemsModel;
87
+ let provider: MinimalTestProvider;
88
+ let testItems: ListItemData[];
89
+
90
+ beforeEach(() => {
91
+ provider = new MinimalTestProvider();
92
+ model = new ListItemsModel(provider as any);
93
+
94
+ testItems = [
95
+ {
96
+ id: '1',
97
+ name: 'Item 1',
98
+ type: 'image',
99
+ size: 1024,
100
+ modified: new Date(),
101
+ aspectRatio: 2/3,
102
+ thumbnailUrl: 'https://example.com/thumb1.jpg'
103
+ },
104
+ {
105
+ id: '2',
106
+ name: 'Item 2',
107
+ type: 'image',
108
+ size: 2048,
109
+ modified: new Date(),
110
+ aspectRatio: 4/3,
111
+ thumbnailUrl: 'https://example.com/thumb2.jpg'
112
+ },
113
+ {
114
+ id: '3',
115
+ name: 'Item 3',
116
+ type: 'image',
117
+ size: 1536,
118
+ modified: new Date(),
119
+ aspectRatio: 1,
120
+ thumbnailUrl: 'https://example.com/thumb3.jpg'
121
+ },
122
+ {
123
+ id: '4',
124
+ name: 'Item 4',
125
+ type: 'image',
126
+ size: 3072,
127
+ modified: new Date(),
128
+ aspectRatio: 1/2,
129
+ thumbnailUrl: 'https://example.com/thumb4.jpg'
130
+ },
131
+ {
132
+ id: '5',
133
+ name: 'Item 5',
134
+ type: 'image',
135
+ size: 512,
136
+ modified: new Date(),
137
+ aspectRatio: 2,
138
+ thumbnailUrl: 'https://example.com/thumb5.jpg'
139
+ }
140
+ ];
141
+
142
+ // Set up model with test items
143
+ model.items = testItems;
144
+ model.totalItemCount = testItems.length;
145
+ });
146
+
147
+ it('should render virtualized masonry view', () => {
148
+ render(
149
+ <VirtualizedMasonryView
150
+ model={model}
151
+ items={testItems}
152
+ containerWidth={800}
153
+ containerHeight={600}
154
+ isHorizontal={false}
155
+ />
156
+ );
157
+
158
+ // Should render the scroll container
159
+ const container = screen.getByRole('generic');
160
+ expect(container).toBeInTheDocument();
161
+ });
162
+
163
+ it('should render items with correct positioning', () => {
164
+ const { container } = render(
165
+ <VirtualizedMasonryView
166
+ model={model}
167
+ items={testItems}
168
+ containerWidth={800}
169
+ containerHeight={600}
170
+ isHorizontal={false}
171
+ />
172
+ );
173
+
174
+ // Should render items with absolute positioning
175
+ const items = container.querySelectorAll('[data-item-id]');
176
+ expect(items.length).toBeGreaterThan(0);
177
+
178
+ // Check that items have absolute positioning
179
+ items.forEach(item => {
180
+ const style = window.getComputedStyle(item);
181
+ expect(style.position).toBe('absolute');
182
+ });
183
+ });
184
+
185
+ it('should handle empty items array', () => {
186
+ render(
187
+ <VirtualizedMasonryView
188
+ model={model}
189
+ items={[]}
190
+ containerWidth={800}
191
+ containerHeight={600}
192
+ isHorizontal={false}
193
+ />
194
+ );
195
+
196
+ expect(screen.getByText('No items to display')).toBeInTheDocument();
197
+ });
198
+
199
+ it('should show debug info when debug visualization is enabled', () => {
200
+ model.setDebugVisualization(true);
201
+
202
+ render(
203
+ <VirtualizedMasonryView
204
+ model={model}
205
+ items={testItems}
206
+ containerWidth={800}
207
+ containerHeight={600}
208
+ isHorizontal={false}
209
+ />
210
+ );
211
+
212
+ // Should show debug overlay
213
+ expect(screen.getByText(/Total:/)).toBeInTheDocument();
214
+ expect(screen.getByText(/Visible:/)).toBeInTheDocument();
215
+ expect(screen.getByText(/Scroll:/)).toBeInTheDocument();
216
+ expect(screen.getByText(/Height:/)).toBeInTheDocument();
217
+ });
218
+
219
+ it('should handle horizontal masonry layout', () => {
220
+ render(
221
+ <VirtualizedMasonryView
222
+ model={model}
223
+ items={testItems}
224
+ containerWidth={800}
225
+ containerHeight={600}
226
+ isHorizontal={true}
227
+ />
228
+ );
229
+
230
+ // Should render without errors
231
+ const container = screen.getByRole('generic');
232
+ expect(container).toBeInTheDocument();
233
+ });
234
+
235
+ it('should apply custom overscan count', () => {
236
+ render(
237
+ <VirtualizedMasonryView
238
+ model={model}
239
+ items={testItems}
240
+ containerWidth={800}
241
+ containerHeight={600}
242
+ isHorizontal={false}
243
+ overscanCount={20}
244
+ />
245
+ );
246
+
247
+ // Should render without errors
248
+ const container = screen.getByRole('generic');
249
+ expect(container).toBeInTheDocument();
250
+ });
251
+ });
@@ -0,0 +1,48 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { FolderOpen, Image } from 'lucide-react';
4
+ import type { MediaBrowserModel } from './MediaBrowserModel';
5
+ import type { IMediaProvider } from './types';
6
+
7
+ export interface AlbumSidebarProps {
8
+ model: MediaBrowserModel;
9
+ provider: IMediaProvider;
10
+ className?: string;
11
+ }
12
+
13
+ export const AlbumSidebar = observer<AlbumSidebarProps>(({ model, provider, className = '' }) => {
14
+ const [albums, setAlbums] = useState<string[]>([]);
15
+
16
+ useEffect(() => {
17
+ provider.getAlbums().then(setAlbums);
18
+ }, [provider]);
19
+
20
+ return (
21
+ <div className={`w-56 border-r border-gray-200 bg-gray-50 overflow-y-auto ${className}`}>
22
+ <div className="p-3">
23
+ <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Albums</h3>
24
+ <button
25
+ onClick={() => model.setAlbum(null)}
26
+ className={`flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors ${
27
+ model.currentAlbum === null ? 'bg-blue-100 text-blue-700' : 'text-gray-700 hover:bg-gray-100'
28
+ }`}
29
+ >
30
+ <Image size={16} />
31
+ <span>All Media</span>
32
+ </button>
33
+ {albums.map(album => (
34
+ <button
35
+ key={album}
36
+ onClick={() => model.setAlbum(album)}
37
+ className={`flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors ${
38
+ model.currentAlbum === album ? 'bg-blue-100 text-blue-700' : 'text-gray-700 hover:bg-gray-100'
39
+ }`}
40
+ >
41
+ <FolderOpen size={16} />
42
+ <span className="truncate" title={album}>{album}</span>
43
+ </button>
44
+ ))}
45
+ </div>
46
+ </div>
47
+ );
48
+ });
@@ -0,0 +1,92 @@
1
+ import React, { useEffect } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { Grid3X3, List, Clock, Search, Loader2 } from 'lucide-react';
4
+ import { BrowserError } from '@anymux/ui/components/browser-error';
5
+ import type { MediaBrowserModel } from './MediaBrowserModel';
6
+ import type { IMediaProvider } from './types';
7
+ import { MediaGrid } from './MediaGrid';
8
+ import { MediaList } from './MediaList';
9
+ import { MediaTimeline } from './MediaTimeline';
10
+ import { MediaPreview } from './MediaPreview';
11
+ import { AlbumSidebar } from './AlbumSidebar';
12
+
13
+ export interface MediaBrowserProps {
14
+ model: MediaBrowserModel;
15
+ provider: IMediaProvider;
16
+ className?: string;
17
+ showSidebar?: boolean;
18
+ }
19
+
20
+ export const MediaBrowser = observer<MediaBrowserProps>(({ model, provider, className = '', showSidebar = true }) => {
21
+ useEffect(() => { model.loadItems(); }, [model]);
22
+
23
+ return (
24
+ <div className={`flex h-full bg-white rounded-xl border border-gray-200 overflow-hidden ${className}`}>
25
+ {showSidebar && <AlbumSidebar model={model} provider={provider} />}
26
+
27
+ <div className="flex-1 flex flex-col min-w-0">
28
+ {/* Toolbar */}
29
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200">
30
+ <div className="relative flex-1 max-w-xs">
31
+ <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
32
+ <input
33
+ type="text"
34
+ placeholder="Search media..."
35
+ value={model.searchQuery}
36
+ onChange={e => model.search(e.target.value)}
37
+ className="w-full pl-9 pr-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
38
+ />
39
+ </div>
40
+
41
+ <div className="flex items-center border border-gray-200 rounded-lg overflow-hidden">
42
+ {([['grid', Grid3X3], ['list', List], ['timeline', Clock]] as const).map(([mode, Icon]) => (
43
+ <button
44
+ key={mode}
45
+ onClick={() => model.setViewMode(mode)}
46
+ className={`p-1.5 ${model.viewMode === mode ? 'bg-blue-50 text-blue-600' : 'text-gray-500 hover:bg-gray-50'}`}
47
+ >
48
+ <Icon size={16} />
49
+ </button>
50
+ ))}
51
+ </div>
52
+
53
+ <select
54
+ value={model.filterByType ?? ''}
55
+ onChange={e => model.setFilter(e.target.value as 'photo' | 'video' | 'audio' || null)}
56
+ className="text-sm border border-gray-200 rounded-lg px-2 py-1.5"
57
+ >
58
+ <option value="">All types</option>
59
+ <option value="photo">Photos</option>
60
+ <option value="video">Videos</option>
61
+ <option value="audio">Audio</option>
62
+ </select>
63
+ </div>
64
+
65
+ {/* Content */}
66
+ <div className="flex-1 overflow-y-auto">
67
+ {model.loading ? (
68
+ <div className="flex items-center justify-center h-64">
69
+ <Loader2 size={24} className="animate-spin text-gray-400" />
70
+ </div>
71
+ ) : model.error ? (
72
+ <BrowserError
73
+ error={model.error}
74
+ context="Media"
75
+ onRetry={() => model.loadItems()}
76
+ />
77
+ ) : model.filteredItems.length === 0 ? (
78
+ <div className="flex items-center justify-center h-64 text-gray-400 text-sm">No media found</div>
79
+ ) : (
80
+ <>
81
+ {model.viewMode === 'grid' && <MediaGrid model={model} />}
82
+ {model.viewMode === 'list' && <MediaList model={model} />}
83
+ {model.viewMode === 'timeline' && <MediaTimeline model={model} />}
84
+ </>
85
+ )}
86
+ </div>
87
+ </div>
88
+
89
+ <MediaPreview model={model} />
90
+ </div>
91
+ );
92
+ });
@@ -0,0 +1,138 @@
1
+ import { makeAutoObservable, runInAction } from 'mobx';
2
+ import type { IMediaProvider, MediaItem, MediaType } from './types';
3
+
4
+ export type MediaViewMode = 'grid' | 'list' | 'timeline';
5
+ export type MediaSortBy = 'date' | 'name' | 'size';
6
+
7
+ export class MediaBrowserModel {
8
+ items: MediaItem[] = [];
9
+ selectedItems = new Set<string>();
10
+ viewMode: MediaViewMode = 'grid';
11
+ currentAlbum: string | null = null;
12
+ loading = false;
13
+ error: string | null = null;
14
+ searchQuery = '';
15
+ sortBy: MediaSortBy = 'date';
16
+ filterByType: MediaType | null = null;
17
+ previewItem: MediaItem | null = null;
18
+
19
+ constructor(private provider: IMediaProvider) {
20
+ makeAutoObservable(this);
21
+ }
22
+
23
+ get filteredItems(): MediaItem[] {
24
+ let result = this.items;
25
+ if (this.searchQuery) {
26
+ const q = this.searchQuery.toLowerCase();
27
+ result = result.filter(i => i.title.toLowerCase().includes(q));
28
+ }
29
+ if (this.filterByType) {
30
+ result = result.filter(i => i.mediaType === this.filterByType);
31
+ }
32
+ return this.sortItems(result);
33
+ }
34
+
35
+ get groupedByDate(): Map<string, MediaItem[]> {
36
+ const groups = new Map<string, MediaItem[]>();
37
+ for (const item of this.filteredItems) {
38
+ const key = item.createdAt.toLocaleDateString();
39
+ const group = groups.get(key) ?? [];
40
+ group.push(item);
41
+ groups.set(key, group);
42
+ }
43
+ return groups;
44
+ }
45
+
46
+ get groupedByAlbum(): Map<string, MediaItem[]> {
47
+ const groups = new Map<string, MediaItem[]>();
48
+ for (const item of this.filteredItems) {
49
+ const key = item.album ?? 'Uncategorized';
50
+ const group = groups.get(key) ?? [];
51
+ group.push(item);
52
+ groups.set(key, group);
53
+ }
54
+ return groups;
55
+ }
56
+
57
+ async loadItems() {
58
+ this.loading = true;
59
+ this.error = null;
60
+ try {
61
+ const items = this.currentAlbum
62
+ ? await this.provider.getByAlbum(this.currentAlbum)
63
+ : await this.provider.listItems();
64
+ runInAction(() => { this.items = items; });
65
+ } catch (err: any) {
66
+ runInAction(() => { this.error = err?.message || 'Failed to load media'; });
67
+ } finally {
68
+ runInAction(() => { this.loading = false; });
69
+ }
70
+ }
71
+
72
+ selectItem(id: string) {
73
+ this.selectedItems.clear();
74
+ this.selectedItems.add(id);
75
+ }
76
+
77
+ toggleSelect(id: string) {
78
+ if (this.selectedItems.has(id)) {
79
+ this.selectedItems.delete(id);
80
+ } else {
81
+ this.selectedItems.add(id);
82
+ }
83
+ }
84
+
85
+ setViewMode(mode: MediaViewMode) {
86
+ this.viewMode = mode;
87
+ }
88
+
89
+ setAlbum(album: string | null) {
90
+ this.currentAlbum = album;
91
+ this.loadItems();
92
+ }
93
+
94
+ async search(query: string) {
95
+ this.searchQuery = query;
96
+ if (query) {
97
+ this.loading = true;
98
+ this.error = null;
99
+ try {
100
+ const items = await this.provider.search(query);
101
+ runInAction(() => { this.items = items; });
102
+ } catch (err: any) {
103
+ runInAction(() => { this.error = err?.message || 'Search failed'; });
104
+ } finally {
105
+ runInAction(() => { this.loading = false; });
106
+ }
107
+ } else {
108
+ this.loadItems();
109
+ }
110
+ }
111
+
112
+ setSort(sortBy: MediaSortBy) {
113
+ this.sortBy = sortBy;
114
+ }
115
+
116
+ setFilter(type: MediaType | null) {
117
+ this.filterByType = type;
118
+ }
119
+
120
+ openPreview(item: MediaItem) {
121
+ this.previewItem = item;
122
+ }
123
+
124
+ closePreview() {
125
+ this.previewItem = null;
126
+ }
127
+
128
+ private sortItems(items: MediaItem[]): MediaItem[] {
129
+ return [...items].sort((a, b) => {
130
+ switch (this.sortBy) {
131
+ case 'date': return b.createdAt.getTime() - a.createdAt.getTime();
132
+ case 'name': return a.title.localeCompare(b.title);
133
+ case 'size': return (b.width ?? 0) * (b.height ?? 0) - (a.width ?? 0) * (a.height ?? 0);
134
+ default: return 0;
135
+ }
136
+ });
137
+ }
138
+ }
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { Play, Music } from 'lucide-react';
4
+ import type { MediaBrowserModel } from './MediaBrowserModel';
5
+ import type { MediaItem } from './types';
6
+
7
+ export interface MediaGridProps {
8
+ model: MediaBrowserModel;
9
+ className?: string;
10
+ }
11
+
12
+ const MediaThumbnail = ({ item, onClick, selected }: { item: MediaItem; onClick: () => void; selected: boolean }) => (
13
+ <button
14
+ onClick={onClick}
15
+ className={`relative aspect-square rounded-lg overflow-hidden cursor-pointer group border-2 transition-all ${
16
+ selected ? 'border-blue-500 ring-2 ring-blue-200' : 'border-transparent hover:border-gray-300'
17
+ }`}
18
+ >
19
+ {item.thumbnail || item.mediaType === 'photo' ? (
20
+ <img
21
+ src={item.thumbnail ?? item.url}
22
+ alt={item.title}
23
+ className="w-full h-full object-cover"
24
+ />
25
+ ) : (
26
+ <div className="w-full h-full bg-gray-100 flex items-center justify-center">
27
+ {item.mediaType === 'video' ? <Play size={32} className="text-gray-400" /> : <Music size={32} className="text-gray-400" />}
28
+ </div>
29
+ )}
30
+ {item.mediaType === 'video' && (
31
+ <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">
32
+ {item.duration ? `${Math.floor(item.duration / 60)}:${String(item.duration % 60).padStart(2, '0')}` : 'Video'}
33
+ </div>
34
+ )}
35
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
36
+ </button>
37
+ );
38
+
39
+ export const MediaGrid = observer<MediaGridProps>(({ model, className = '' }) => (
40
+ <div className={`grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-1 p-2 ${className}`}>
41
+ {model.filteredItems.map(item => (
42
+ <MediaThumbnail
43
+ key={item.id}
44
+ item={item}
45
+ selected={model.selectedItems.has(item.id)}
46
+ onClick={() => model.openPreview(item)}
47
+ />
48
+ ))}
49
+ </div>
50
+ ));
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { Image, Play, Music } from 'lucide-react';
4
+ import type { MediaBrowserModel } from './MediaBrowserModel';
5
+ import type { MediaItem } from './types';
6
+
7
+ export interface MediaListProps {
8
+ model: MediaBrowserModel;
9
+ className?: string;
10
+ }
11
+
12
+ const mediaIcon = (item: MediaItem) => {
13
+ switch (item.mediaType) {
14
+ case 'photo': return <Image size={16} className="text-green-500" />;
15
+ case 'video': return <Play size={16} className="text-blue-500" />;
16
+ case 'audio': return <Music size={16} className="text-purple-500" />;
17
+ }
18
+ };
19
+
20
+ export const MediaList = observer<MediaListProps>(({ model, className = '' }) => (
21
+ <div className={`divide-y divide-gray-100 ${className}`}>
22
+ {model.filteredItems.map(item => (
23
+ <button
24
+ key={item.id}
25
+ onClick={() => model.openPreview(item)}
26
+ className={`flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-gray-50 transition-colors ${
27
+ model.selectedItems.has(item.id) ? 'bg-blue-50' : ''
28
+ }`}
29
+ >
30
+ <div className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 bg-gray-100 flex items-center justify-center">
31
+ {item.thumbnail ? (
32
+ <img src={item.thumbnail} alt={item.title} className="w-full h-full object-cover" />
33
+ ) : (
34
+ mediaIcon(item)
35
+ )}
36
+ </div>
37
+ <div className="flex-1 min-w-0">
38
+ <p className="text-sm font-medium text-gray-900 truncate" title={item.title}>{item.title}</p>
39
+ <p className="text-xs text-gray-500">
40
+ {item.mediaType} {item.album && `· ${item.album}`} · {item.createdAt.toLocaleDateString()}
41
+ </p>
42
+ </div>
43
+ <div className="flex items-center gap-2">
44
+ {mediaIcon(item)}
45
+ </div>
46
+ </button>
47
+ ))}
48
+ </div>
49
+ ));
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
4
+ import type { MediaBrowserModel } from './MediaBrowserModel';
5
+
6
+ export interface MediaPreviewProps {
7
+ model: MediaBrowserModel;
8
+ className?: string;
9
+ }
10
+
11
+ export const MediaPreview = observer<MediaPreviewProps>(({ model, className = '' }) => {
12
+ const item = model.previewItem;
13
+ if (!item) return null;
14
+
15
+ const items = model.filteredItems;
16
+ const idx = items.findIndex(i => i.id === item.id);
17
+
18
+ const goPrev = () => {
19
+ if (idx > 0) model.openPreview(items[idx - 1]);
20
+ };
21
+ const goNext = () => {
22
+ if (idx < items.length - 1) model.openPreview(items[idx + 1]);
23
+ };
24
+
25
+ return (
26
+ <div className={`fixed inset-0 z-50 bg-black/90 flex items-center justify-center ${className}`}>
27
+ <button onClick={() => model.closePreview()} className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10">
28
+ <X size={24} />
29
+ </button>
30
+
31
+ <button onClick={goPrev} disabled={idx <= 0} className="absolute left-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 disabled:opacity-30">
32
+ <ChevronLeft size={32} />
33
+ </button>
34
+
35
+ <div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center">
36
+ {item.mediaType === 'photo' && (
37
+ <img src={item.url} alt={item.title} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
38
+ )}
39
+ {item.mediaType === 'video' && (
40
+ <video src={item.url} controls className="max-w-full max-h-[80vh] rounded-lg" />
41
+ )}
42
+ {item.mediaType === 'audio' && (
43
+ <div className="bg-gray-900 rounded-xl p-8 flex flex-col items-center gap-4">
44
+ <div className="w-48 h-48 bg-gray-800 rounded-xl flex items-center justify-center text-6xl">🎵</div>
45
+ <audio src={item.url} controls className="w-80" />
46
+ </div>
47
+ )}
48
+ <div className="mt-4 text-center">
49
+ <p className="text-white font-medium">{item.title}</p>
50
+ <p className="text-white/60 text-sm">{item.createdAt.toLocaleDateString()}</p>
51
+ </div>
52
+ </div>
53
+
54
+ <button onClick={goNext} disabled={idx >= items.length - 1} className="absolute right-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 disabled:opacity-30">
55
+ <ChevronRight size={32} />
56
+ </button>
57
+
58
+ <a href={item.url} download className="absolute bottom-4 right-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10">
59
+ <Download size={20} />
60
+ </a>
61
+ </div>
62
+ );
63
+ });