@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.
- package/dist/ExplorerLayout-CSIJd7N4.js +105 -0
- package/dist/ExplorerLayout-CSIJd7N4.js.map +1 -0
- package/dist/FileBrowserContext-B6jixa2j.js +11 -0
- package/dist/FileBrowserContext-B6jixa2j.js.map +1 -0
- package/dist/calendar-DSlrbHoj.js +761 -0
- package/dist/calendar-DSlrbHoj.js.map +1 -0
- package/dist/calendar.d.ts +3 -0
- package/dist/calendar.js +3 -0
- package/dist/contacts-DQXTZzHc.js +539 -0
- package/dist/contacts-DQXTZzHc.js.map +1 -0
- package/dist/contacts.d.ts +3 -0
- package/dist/contacts.js +3 -0
- package/dist/file-browser-m5atC3kF.js +6755 -0
- package/dist/file-browser-m5atC3kF.js.map +1 -0
- package/dist/file-browser.d.ts +11 -0
- package/dist/file-browser.js +9 -0
- package/dist/git-B55e6LL-.js +561 -0
- package/dist/git-B55e6LL-.js.map +1 -0
- package/dist/git.d.ts +2 -0
- package/dist/git.js +3 -0
- package/dist/iconMap-V4B8P-Uh.js +206 -0
- package/dist/iconMap-V4B8P-Uh.js.map +1 -0
- package/dist/icons-CIsIOZXR.js +0 -0
- package/dist/icons.d.ts +2 -0
- package/dist/icons.js +4 -0
- package/dist/index-BNmNIWBL.d.ts +71 -0
- package/dist/index-BNmNIWBL.d.ts.map +1 -0
- package/dist/index-Bryv_GCG.d.ts +1481 -0
- package/dist/index-Bryv_GCG.d.ts.map +1 -0
- package/dist/index-CuQIjSXs.d.ts +134 -0
- package/dist/index-CuQIjSXs.d.ts.map +1 -0
- package/dist/index-DSu19mq0.d.ts +153 -0
- package/dist/index-DSu19mq0.d.ts.map +1 -0
- package/dist/index-DmsyeHFr.d.ts +149 -0
- package/dist/index-DmsyeHFr.d.ts.map +1 -0
- package/dist/index-DxnJ8FYM.d.ts +17 -0
- package/dist/index-DxnJ8FYM.d.ts.map +1 -0
- package/dist/index-DzfY1Tok.d.ts +32 -0
- package/dist/index-DzfY1Tok.d.ts.map +1 -0
- package/dist/index-Ml_SgiKa.d.ts +1847 -0
- package/dist/index-Ml_SgiKa.d.ts.map +1 -0
- package/dist/index-kHr9udZD.d.ts +1025 -0
- package/dist/index-kHr9udZD.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/layout-Ca_4r8ka.js +89 -0
- package/dist/layout-Ca_4r8ka.js.map +1 -0
- package/dist/layout.d.ts +2 -0
- package/dist/layout.js +5 -0
- package/dist/list-CxfT6hix.js +6831 -0
- package/dist/list-CxfT6hix.js.map +1 -0
- package/dist/list.d.ts +2 -0
- package/dist/list.js +5 -0
- package/dist/media-DZ292aKK.js +557 -0
- package/dist/media-DZ292aKK.js.map +1 -0
- package/dist/media.d.ts +3 -0
- package/dist/media.js +3 -0
- package/dist/tree-Dd9Z0Aso.js +3351 -0
- package/dist/tree-Dd9Z0Aso.js.map +1 -0
- package/dist/tree.d.ts +2 -0
- package/dist/tree.js +6 -0
- package/dist/types-common-CB3kRek8.d.ts +26 -0
- package/dist/types-common-CB3kRek8.d.ts.map +1 -0
- package/dist/utils-B4fdKKsy.js +3 -0
- package/package.json +109 -0
- package/src/calendar/AgendaView.tsx +37 -0
- package/src/calendar/CalendarBrowser.tsx +90 -0
- package/src/calendar/CalendarModel.ts +142 -0
- package/src/calendar/CalendarSidebar.tsx +81 -0
- package/src/calendar/DayView.tsx +76 -0
- package/src/calendar/EventCard.tsx +51 -0
- package/src/calendar/MockCalendarProvider.ts +98 -0
- package/src/calendar/MonthView.tsx +77 -0
- package/src/calendar/WeekView.tsx +129 -0
- package/src/calendar/index.ts +18 -0
- package/src/calendar/types.ts +25 -0
- package/src/contacts/ContactAvatar.tsx +35 -0
- package/src/contacts/ContactBrowser.tsx +56 -0
- package/src/contacts/ContactCard.tsx +37 -0
- package/src/contacts/ContactDetail.tsx +63 -0
- package/src/contacts/ContactGroupSidebar.tsx +40 -0
- package/src/contacts/ContactList.tsx +32 -0
- package/src/contacts/ContactListModel.ts +120 -0
- package/src/contacts/MockContactProvider.ts +77 -0
- package/src/contacts/index.ts +17 -0
- package/src/contacts/types.ts +26 -0
- package/src/demos/CalendarBrowserDemo.tsx +15 -0
- package/src/demos/ContactBrowserDemo.tsx +15 -0
- package/src/demos/MediaBrowserDemo.tsx +15 -0
- package/src/file-browser/adapters/DocumentViewerAdapter.ts +371 -0
- package/src/file-browser/adapters/FileSystemBridge.ts +168 -0
- package/src/file-browser/adapters/GitBrowserAdapter.ts +546 -0
- package/src/file-browser/adapters/README.md +504 -0
- package/src/file-browser/adapters/index.ts +27 -0
- package/src/file-browser/adapters/types.ts +70 -0
- package/src/file-browser/architecture.md +645 -0
- package/src/file-browser/components/CreateItemDialog.tsx +71 -0
- package/src/file-browser/components/DeleteConfirmDialog.tsx +58 -0
- package/src/file-browser/components/FileBrowser.tsx +473 -0
- package/src/file-browser/components/FileBrowserContent.tsx +209 -0
- package/src/file-browser/components/FileBrowserHeader.tsx +151 -0
- package/src/file-browser/components/FileBrowserToolbar.tsx +145 -0
- package/src/file-browser/components/LeftPanel/LeftPanel.tsx +103 -0
- package/src/file-browser/components/LeftPanel/LeftPanelTabs.tsx +70 -0
- package/src/file-browser/components/LeftPanel/TreeNavigationView.tsx +256 -0
- package/src/file-browser/components/PreviewPane.tsx +146 -0
- package/src/file-browser/components/RightPanel/FilePreview.tsx +219 -0
- package/src/file-browser/components/RightPanel/RightPanel.tsx +186 -0
- package/src/file-browser/components/RightPanel/RightPanelToolbar.tsx +113 -0
- package/src/file-browser/components/UploadProgress.tsx +123 -0
- package/src/file-browser/components/ViewerHost.tsx +208 -0
- package/src/file-browser/components/mobile/MobileNavigation.tsx +227 -0
- package/src/file-browser/components/navigation/NavigationButtons.tsx +171 -0
- package/src/file-browser/components/shared/ErrorBoundary.tsx +116 -0
- package/src/file-browser/components/shared/FileBrowserItem.tsx +195 -0
- package/src/file-browser/components/shared/FileIcon.tsx +169 -0
- package/src/file-browser/components/toolbar/ViewModeToggle.tsx +200 -0
- package/src/file-browser/components/views/ListView/ListView.tsx +484 -0
- package/src/file-browser/components/views/ThumbnailView/ThumbnailView.tsx +323 -0
- package/src/file-browser/components/views/TreeView/TreeNode.tsx +186 -0
- package/src/file-browser/components/views/TreeView/TreeNodeList.tsx +191 -0
- package/src/file-browser/components/views/TreeView/TreeView.tsx +200 -0
- package/src/file-browser/components/views/TreemapView/TreemapView.tsx +339 -0
- package/src/file-browser/context/FileBrowserContext.tsx +13 -0
- package/src/file-browser/examples/BasicUsage.tsx +20 -0
- package/src/file-browser/index.ts +98 -0
- package/src/file-browser/models/FileBrowserModel.ts +623 -0
- package/src/file-browser/models/LeftPanelManagerModel.ts +105 -0
- package/src/file-browser/models/NavigationManagerModel.ts +312 -0
- package/src/file-browser/models/ResponsiveLayoutManagerModel.ts +437 -0
- package/src/file-browser/models/RightPanelManagerModel.ts +190 -0
- package/src/file-browser/models/SelectionManagerModel.ts +252 -0
- package/src/file-browser/models/ToolbarManagerModel.ts +144 -0
- package/src/file-browser/models/UploadModel.ts +147 -0
- package/src/file-browser/models/ViewModeManagerModel.ts +185 -0
- package/src/file-browser/models/ViewerHostModel.ts +44 -0
- package/src/file-browser/models/ui/ListViewUIModel.ts +265 -0
- package/src/file-browser/models/ui/PreviewUIModel.ts +297 -0
- package/src/file-browser/models/ui/ThumbnailViewUIModel.ts +254 -0
- package/src/file-browser/models/ui/TreeViewUIModel.ts +128 -0
- package/src/file-browser/models/ui/TreemapViewUIModel.ts +350 -0
- package/src/file-browser/providers/FileSystemListProvider.ts +552 -0
- package/src/file-browser/providers/FileSystemProvider.ts +401 -0
- package/src/file-browser/providers/FileSystemTreeProvider.ts +231 -0
- package/src/file-browser/providers/GitProvider.ts +337 -0
- package/src/file-browser/providers/GitRepositoryProvider.ts +376 -0
- package/src/file-browser/providers/IFileBrowserProvider.ts +56 -0
- package/src/file-browser/providers/MemoryProvider.ts +303 -0
- package/src/file-browser/providers/index.ts +4 -0
- package/src/file-browser/registry/ViewerRegistry.ts +551 -0
- package/src/file-browser/registry/types.ts +144 -0
- package/src/file-browser/scripts/performanceBenchmark.ts +553 -0
- package/src/file-browser/services/ThumbnailCacheService.ts +128 -0
- package/src/file-browser/tasks.md +537 -0
- package/src/file-browser/types/FileBrowserTypes.ts +126 -0
- package/src/file-browser/types/ProviderTypes.ts +155 -0
- package/src/file-browser/types/UITypes.ts +235 -0
- package/src/file-browser/types/ViewModeTypes.ts +150 -0
- package/src/file-browser/utils/gestures.ts +327 -0
- package/src/file-browser/utils/performance.ts +563 -0
- package/src/file-browser/viewers/ImageViewer.tsx +163 -0
- package/src/file-browser/viewers/ImageViewerModel.ts +79 -0
- package/src/file-browser/viewers/TextViewer.tsx +95 -0
- package/src/file-browser/viewers/UnsupportedFileViewer.tsx +57 -0
- package/src/file-browser/viewers/index.ts +61 -0
- package/src/git/BranchList.tsx +128 -0
- package/src/git/CommitGraph.tsx +239 -0
- package/src/git/CommitList.tsx +258 -0
- package/src/git/DiffViewer.tsx +219 -0
- package/src/git/index.ts +4 -0
- package/src/icons/iconMap.ts +146 -0
- package/src/icons/index.ts +9 -0
- package/src/index.ts +13 -0
- package/src/layout/README.md +307 -0
- package/src/layout/components/ExplorerLayout/ExplorerLayout.tsx +178 -0
- package/src/layout/examples/SimpleExample.tsx +60 -0
- package/src/layout/index.ts +6 -0
- package/src/lib/utils.ts +1 -0
- package/src/list/README.md +303 -0
- package/src/list/architecture.md +807 -0
- package/src/list/components/CalculatedGridView.tsx +252 -0
- package/src/list/components/DragPreview.tsx +102 -0
- package/src/list/components/ListContextMenu.tsx +274 -0
- package/src/list/components/ListItem.tsx +761 -0
- package/src/list/components/ListItems.tsx +919 -0
- package/src/list/components/MasonryView.tsx +241 -0
- package/src/list/components/SearchFilter.tsx +44 -0
- package/src/list/components/TreemapView.tsx +709 -0
- package/src/list/components/ViewSizeControls.tsx +205 -0
- package/src/list/components/ViewTypeSelector.tsx +312 -0
- package/src/list/components/VirtualizedDetailsView.tsx +231 -0
- package/src/list/components/VirtualizedGrid.tsx +164 -0
- package/src/list/components/VirtualizedList.tsx +154 -0
- package/src/list/components/VirtualizedMasonryView.tsx +344 -0
- package/src/list/components/shared/EmptyState.tsx +103 -0
- package/src/list/components/shared/ErrorBoundary.tsx +123 -0
- package/src/list/components/shared/ErrorDisplay.tsx +100 -0
- package/src/list/components/shared/ListLoader.tsx +146 -0
- package/src/list/components/shared/LoadingIndicator.tsx +80 -0
- package/src/list/index.ts +92 -0
- package/src/list/models/ListItemsModel.ts +1301 -0
- package/src/list/models/TreemapModel.ts +204 -0
- package/src/list/providers/ListItemsProvider.ts +313 -0
- package/src/list/providers/TestListProvider.ts +604 -0
- package/src/list/tasks.md +937 -0
- package/src/list/types/ListTypes.ts +178 -0
- package/src/list/utils/BenchmarkLogger.ts +243 -0
- package/src/list/utils/DragDropManager.ts +320 -0
- package/src/list/utils/GridLayoutCalculator.ts +290 -0
- package/src/list/utils/ListAccessibility.ts +367 -0
- package/src/list/utils/ListKeyboard.ts +414 -0
- package/src/list/utils/MasonryLayoutCalculator.ts +302 -0
- package/src/list/utils/MasonryLayoutEngine.ts +401 -0
- package/src/list/utils/__tests__/MasonryLayoutEngine.test.ts +157 -0
- package/src/list/utils/__tests__/VirtualizedMasonryView.test.tsx +251 -0
- package/src/media/AlbumSidebar.tsx +48 -0
- package/src/media/MediaBrowser.tsx +92 -0
- package/src/media/MediaBrowserModel.ts +138 -0
- package/src/media/MediaGrid.tsx +50 -0
- package/src/media/MediaList.tsx +49 -0
- package/src/media/MediaPreview.tsx +63 -0
- package/src/media/MediaTimeline.tsx +38 -0
- package/src/media/MockMediaProvider.ts +70 -0
- package/src/media/index.ts +18 -0
- package/src/media/types.ts +21 -0
- package/src/styles/variables.css +60 -0
- package/src/tree/DEVELOPMENT_SUMMARY.md +170 -0
- package/src/tree/__tests__/TreeModel.test.ts +16 -0
- package/src/tree/architecture.md +530 -0
- package/src/tree/components/Tree.tsx +283 -0
- package/src/tree/components/TreeCheckbox.tsx +147 -0
- package/src/tree/components/TreeContextMenu.tsx +139 -0
- package/src/tree/components/TreeNodeList.tsx +329 -0
- package/src/tree/components/TreeTable.tsx +382 -0
- package/src/tree/index.ts +58 -0
- package/src/tree/models/TreeModel.ts +839 -0
- package/src/tree/providers/SimpleTreeProvider.ts +463 -0
- package/src/tree/providers/TestTreeProvider.ts +946 -0
- package/src/tree/providers/TreeProvider.ts +308 -0
- package/src/tree/tasks.md +2046 -0
- package/src/tree/types/TreeTypes.ts +279 -0
- package/src/tree/utils/SelectionTheme.ts +150 -0
- package/src/tree/utils/logger.ts +203 -0
- 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
|
+
});
|