diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json
index 5e2284e37..41aa7b9af 100644
--- a/src/app/i18n/locales/de.json
+++ b/src/app/i18n/locales/de.json
@@ -474,6 +474,9 @@
"title": "Deine Fotos konnten nicht geladen werden",
"description": "Überprüfe deine Verbindung und versuche es erneut",
"retry": "Erneut versuchen"
+ },
+ "filters": {
+ "allDevices": "Alle Geräte"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json
index e2d137546..fbb414075 100644
--- a/src/app/i18n/locales/en.json
+++ b/src/app/i18n/locales/en.json
@@ -495,6 +495,9 @@
"title": "We couldn't load your photos",
"description": "Check your connection and try again",
"retry": "Try again"
+ },
+ "filters": {
+ "allDevices": "All devices"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json
index d43941b69..770cc7c6b 100644
--- a/src/app/i18n/locales/es.json
+++ b/src/app/i18n/locales/es.json
@@ -474,6 +474,9 @@
"title": "No hemos podido cargar tus fotos",
"description": "Comprueba tu conexión e inténtalo de nuevo",
"retry": "Reintentar"
+ },
+ "filters": {
+ "allDevices": "Todos los dispositivos"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json
index a65aa45ce..f8a13617b 100644
--- a/src/app/i18n/locales/fr.json
+++ b/src/app/i18n/locales/fr.json
@@ -474,6 +474,9 @@
"title": "Impossible de charger vos photos",
"description": "Vérifiez votre connexion et réessayez",
"retry": "Réessayer"
+ },
+ "filters": {
+ "allDevices": "Tous les appareils"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json
index c740263d4..fa1241a4d 100644
--- a/src/app/i18n/locales/it.json
+++ b/src/app/i18n/locales/it.json
@@ -495,6 +495,9 @@
"title": "Impossibile caricare le tue foto",
"description": "Controlla la connessione e riprova",
"retry": "Riprova"
+ },
+ "filters": {
+ "allDevices": "Tutti i dispositivi"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json
index ceaf96953..8d8e29229 100644
--- a/src/app/i18n/locales/pt.json
+++ b/src/app/i18n/locales/pt.json
@@ -495,6 +495,9 @@
"title": "Não foi possível carregar suas fotos",
"description": "Verifique sua conexão e tente novamente",
"retry": "Tentar novamente"
+ },
+ "filters": {
+ "allDevices": "Todos os dispositivos"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json
index e14531e32..772fe9d5d 100644
--- a/src/app/i18n/locales/ru.json
+++ b/src/app/i18n/locales/ru.json
@@ -474,6 +474,9 @@
"title": "Не удалось загрузить фото",
"description": "Проверьте подключение и попробуйте снова",
"retry": "Повторить"
+ },
+ "filters": {
+ "allDevices": "Все устройства"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json
index 5cbdc87a4..a3834c1f6 100644
--- a/src/app/i18n/locales/tw.json
+++ b/src/app/i18n/locales/tw.json
@@ -495,6 +495,9 @@
"title": "無法載入照片",
"description": "請檢查網路連線後再試一次",
"retry": "重試"
+ },
+ "filters": {
+ "allDevices": "所有裝置"
}
},
"favorites": {
diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json
index a864fba01..56304bd95 100644
--- a/src/app/i18n/locales/zh.json
+++ b/src/app/i18n/locales/zh.json
@@ -495,6 +495,9 @@
"title": "无法加载照片",
"description": "请检查网络连接后重试",
"retry": "重试"
+ },
+ "filters": {
+ "allDevices": "所有设备"
}
},
"favorites": {
diff --git a/src/views/Photos/PhotosView.test.tsx b/src/views/Photos/PhotosView.test.tsx
index aa2fcb495..b59e6df15 100644
--- a/src/views/Photos/PhotosView.test.tsx
+++ b/src/views/Photos/PhotosView.test.tsx
@@ -87,6 +87,52 @@ describe('Photos view', () => {
expect(photosClient.listDevices).toHaveBeenCalledTimes(2);
});
+ describe('Filtering by device', () => {
+ const openDeviceFilter = async () => {
+ fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ }));
+ };
+
+ test('When the user has a single device, then there is no device filter', async () => {
+ photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS')]);
+
+ render();
+
+ await waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument());
+ expect(screen.queryByRole('button', { name: /views.photos.filters.allDevices/ })).not.toBeInTheDocument();
+ });
+
+ test('When the user has several devices, then they can choose one of them', async () => {
+ photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS'), device('Pixel', 'EXISTS')]);
+
+ render();
+ await openDeviceFilter();
+
+ expect((await screen.findAllByRole('menuitem')).map((option) => option.textContent)).toStrictEqual([
+ 'views.photos.filters.allDevices',
+ 'iPhone',
+ 'Pixel',
+ ]);
+ expect(screen.getByRole('menuitem', { name: /views.photos.filters.allDevices/ })).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ );
+ });
+
+ test('When the user chooses a device, then the filter shows it until they go back to all devices', async () => {
+ photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS'), device('Pixel', 'EXISTS')]);
+
+ render();
+ await openDeviceFilter();
+ fireEvent.click(await screen.findByRole('menuitem', { name: /Pixel/ }));
+
+ fireEvent.click(await screen.findByRole('button', { name: /Pixel/ }));
+ expect(await screen.findByRole('menuitem', { name: /Pixel/ })).toHaveAttribute('aria-current', 'true');
+
+ fireEvent.click(await screen.findByRole('menuitem', { name: /views.photos.filters.allDevices/ }));
+ expect(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ })).toBeInTheDocument();
+ });
+ });
+
test('When the devices cannot be loaded, then the error is reported and no empty state is shown', async () => {
const networkError = new Error('Network error');
photosClient.listDevices.mockRejectedValue(networkError);
diff --git a/src/views/Photos/PhotosView.tsx b/src/views/Photos/PhotosView.tsx
index 75588da04..4239de437 100644
--- a/src/views/Photos/PhotosView.tsx
+++ b/src/views/Photos/PhotosView.tsx
@@ -1,31 +1,39 @@
-import { Helmet } from 'react-helmet-async';
-import { useTranslationContext } from 'app/i18n/provider/TranslationProvider';
+import { useState } from 'react';
+import PhotosDeviceFilter from './components/PhotosDeviceFilter';
import PhotosEmptyState from './components/PhotosEmptyState';
import PhotosErrorState from './components/PhotosErrorState';
import PhotosGridSkeleton from './components/PhotosGridSkeleton';
-import { PhotoDevicesState, usePhotoDevices } from './hooks/usePhotoDevices';
-
-const PhotosContent = ({ state, onRetry }: { state: PhotoDevicesState; onRetry: () => void }) => {
- if (state.status === 'loading') return ;
- if (state.status === 'error') return ;
- if (state.status === 'loaded' && state.devices.length === 0) return ;
- return null;
-};
+import PhotosLayout from './components/PhotosLayout';
+import { usePhotoDevices } from './hooks/usePhotoDevices';
const PhotosView = (): JSX.Element => {
- const { translate } = useTranslationContext();
const { state, retry } = usePhotoDevices();
+ const [selectedDeviceUuid, setSelectedDeviceUuid] = useState(null);
+
+ const devices = state.status === 'loaded' ? state.devices : [];
+ const hasSeveralDevices = devices.length > 1;
+
+ const content = {
+ loading: ,
+ error: ,
+ // TODO: show the photos of the chosen devices when there are devices, once the gallery is added
+ loaded: devices.length === 0 && ,
+ };
return (
-
-
- {translate('sideNav.photos')} - Internxt Drive
-
-
- {translate('views.photos.head')}
-
-
-
+
+ )
+ }
+ >
+ {content[state.status]}
+
);
};
diff --git a/src/views/Photos/components/PhotosDeviceFilter.tsx b/src/views/Photos/components/PhotosDeviceFilter.tsx
new file mode 100644
index 000000000..f6c1cc383
--- /dev/null
+++ b/src/views/Photos/components/PhotosDeviceFilter.tsx
@@ -0,0 +1,29 @@
+import { DeviceMobileIcon } from '@phosphor-icons/react';
+import { useTranslationContext } from 'app/i18n/provider/TranslationProvider';
+import { PhotoDevice } from '../types';
+import PhotosFilterMenu from './PhotosFilterMenu';
+
+interface PhotosDeviceFilterProps {
+ devices: PhotoDevice[];
+ selectedDeviceUuid: string | null;
+ onSelect: (deviceUuid: string | null) => void;
+}
+
+const PhotosDeviceFilter = ({ devices, selectedDeviceUuid, onSelect }: PhotosDeviceFilterProps): JSX.Element => {
+ const { translate } = useTranslationContext();
+
+ return (
+ ({
+ id: device.uuid,
+ label: device.name,
+ icon: ,
+ }))}
+ selectedId={selectedDeviceUuid}
+ onSelect={onSelect}
+ />
+ );
+};
+
+export default PhotosDeviceFilter;
diff --git a/src/views/Photos/components/PhotosFilterMenu.tsx b/src/views/Photos/components/PhotosFilterMenu.tsx
new file mode 100644
index 000000000..605e23b77
--- /dev/null
+++ b/src/views/Photos/components/PhotosFilterMenu.tsx
@@ -0,0 +1,77 @@
+import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
+import { CheckIcon } from '@phosphor-icons/react';
+import { ReactNode } from 'react';
+import SearchFilterPill from 'views/Home/components/SearchFilterPill';
+
+export interface PhotosFilterOption {
+ id: T;
+ label: string;
+ icon?: ReactNode;
+}
+
+interface PhotosFilterMenuProps {
+ allLabel: string;
+ options: PhotosFilterOption[];
+ selectedId: T | null;
+ onSelect: (id: T | null) => void;
+}
+
+const PhotosFilterMenuRow = ({
+ label,
+ icon,
+ isSelected,
+ onClick,
+}: {
+ label: string;
+ icon?: ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+}) => (
+
+);
+
+/** Single choice filter shown as a pill, with an "all" option that clears it. */
+const PhotosFilterMenu = ({
+ allLabel,
+ options,
+ selectedId,
+ onSelect,
+}: PhotosFilterMenuProps): JSX.Element => {
+ const selectedOption = options.find((option) => option.id === selectedId);
+
+ return (
+
+ );
+};
+
+export default PhotosFilterMenu;
diff --git a/src/views/Photos/components/PhotosLayout.tsx b/src/views/Photos/components/PhotosLayout.tsx
new file mode 100644
index 000000000..e11598f55
--- /dev/null
+++ b/src/views/Photos/components/PhotosLayout.tsx
@@ -0,0 +1,27 @@
+import { ReactNode } from 'react';
+import { Helmet } from 'react-helmet-async';
+import { useTranslationContext } from 'app/i18n/provider/TranslationProvider';
+
+interface PhotosLayoutProps {
+ filters?: ReactNode;
+ children?: ReactNode;
+}
+
+const PhotosLayout = ({ filters, children }: PhotosLayoutProps): JSX.Element => {
+ const { translate } = useTranslationContext();
+
+ return (
+
+
+ {translate('sideNav.photos')} - Internxt Drive
+
+
+ {translate('views.photos.head')}
+ {filters && {filters}
}
+
+ {children}
+
+ );
+};
+
+export default PhotosLayout;