diff --git a/__tests__/src/components/AttributionPanel.test.js b/__tests__/src/components/AttributionPanel.test.js
index 5219ebb35..9ece92a51 100644
--- a/__tests__/src/components/AttributionPanel.test.js
+++ b/__tests__/src/components/AttributionPanel.test.js
@@ -1,55 +1,58 @@
/**
* @jest-environment-options { "resources": "usable" }
*/
-import { render, screen, waitFor } from '@tests/utils/test-utils';
+import { render, screen, waitFor } from "@tests/utils/test-utils";
-import { AttributionPanel } from '../../../src/components/AttributionPanel';
+import { AttributionPanel } from "../../../src/components/AttributionPanel";
/**
* Helper function to create a shallow wrapper around AttributionPanel
*/
function createWrapper(props) {
- return render(
- ,
- { preloadedState: { companionWindows: { xyz: { content: 'attribution' } } } },
- );
+ return render(, {
+ preloadedState: { companionWindows: { xyz: { content: "attribution" } } },
+ });
}
-describe('AttributionPanel', () => {
- it('renders the required statement', () => {
+describe("AttributionPanel", () => {
+ it("renders the required statement", () => {
const requiredStatement = [
- { label: 'required statement', values: ['must be shown'] },
+ { label: "required statement", values: ["must be shown"] },
];
createWrapper({ requiredStatement });
- expect(screen.getByText('required statement')).toBeInTheDocument();
- expect(screen.getByText('must be shown')).toBeInTheDocument();
+ expect(screen.getByText("required statement")).toBeInTheDocument();
+ expect(screen.getByText("must be shown")).toBeInTheDocument();
});
- it('renders the rights statement', () => {
- createWrapper({ rights: ['http://example.com', 'http://stanford.edu'] });
+ it("renders the rights statement", () => {
+ createWrapper({ rights: ["http://example.com", "http://stanford.edu"] });
- expect(screen.getByText('License')).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'http://example.com' })).toHaveAttribute('href', 'http://example.com');
- expect(screen.getByRole('link', { name: 'http://stanford.edu' })).toHaveAttribute('href', 'http://stanford.edu');
+ expect(screen.getByText("License")).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "http://example.com" }),
+ ).toHaveAttribute("href", "http://example.com");
+ expect(
+ screen.getByRole("link", { name: "http://stanford.edu" }),
+ ).toHaveAttribute("href", "http://stanford.edu");
});
- it('does not render the rights statement if it is empty', () => {
+ it("does not render the rights statement if it is empty", () => {
createWrapper({ rights: [] });
- expect(screen.queryByText('License')).not.toBeInTheDocument();
+ expect(screen.queryByText("License")).not.toBeInTheDocument();
});
// Requires canvas to handle img loading.
- it.skip('renders the manifest logo', async () => {
- const manifestLogo = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mMMDQmtBwADgwF/Op8FmAAAAABJRU5ErkJggg==';
+ it("renders the manifest logo", async () => {
+ const manifestLogo =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mMMDQmtBwADgwF/Op8FmAAAAABJRU5ErkJggg==";
const { container } = createWrapper({ manifestLogo });
- await waitFor(() => { expect(container.querySelector('img')).toBeInTheDocument(); }); // eslint-disable-line testing-library/no-container, testing-library/no-node-access
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ expect(container.querySelector("img")).toBeInTheDocument();
+ });
- expect(container.querySelector('img')).toHaveAttribute('src', manifestLogo); // eslint-disable-line testing-library/no-container, testing-library/no-node-access
+ expect(container.querySelector("img")).toHaveAttribute("src", manifestLogo); // eslint-disable-line testing-library/no-container, testing-library/no-node-access
});
});
diff --git a/package.json b/package.json
index a2d71a1c0..801046abf 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,6 @@
"react-dnd-touch-backend": "^16.0.0",
"react-error-boundary": "^5.0.0",
"react-full-screen": "^1.1.1",
- "react-image": "^4.0.1",
"react-intersection-observer": "^10.0.0",
"react-mosaic-component2": "^7.0.0",
"react-redux": "^8.0.0 || ^9.0.0",
diff --git a/src/components/AttributionPanel.jsx b/src/components/AttributionPanel.jsx
index d15288a99..d01f77fe5 100644
--- a/src/components/AttributionPanel.jsx
+++ b/src/components/AttributionPanel.jsx
@@ -1,18 +1,18 @@
-import PropTypes from 'prop-types';
-import { styled } from '@mui/material/styles';
-import Typography from '@mui/material/Typography';
-import Link from '@mui/material/Link';
-import Skeleton from '@mui/material/Skeleton';
-import { useTranslation } from 'react-i18next';
-import { Img } from 'react-image';
-import CompanionWindow from '../containers/CompanionWindow';
-import { CompanionWindowSection } from './CompanionWindowSection';
-import LabelValueMetadata from '../containers/LabelValueMetadata';
-import ns from '../config/css-ns';
-import { PluginHook } from './PluginHook';
+import PropTypes from "prop-types";
+import { styled } from "@mui/material/styles";
+import Typography from "@mui/material/Typography";
+import Link from "@mui/material/Link";
+import Skeleton from "@mui/material/Skeleton";
+import { useTranslation } from "react-i18next";
+import CompanionWindow from "../containers/CompanionWindow";
+import { CompanionWindowSection } from "./CompanionWindowSection";
+import LabelValueMetadata from "../containers/LabelValueMetadata";
+import ns from "../config/css-ns";
+import { PluginHook } from "./PluginHook";
+import { ImageWithFallback } from "./ImageWithFallback";
-const StyledLogo = styled(Img)(() => ({
- maxWidth: '100%',
+const StyledLogo = styled(ImageWithFallback)(() => ({
+ maxWidth: "100%",
}));
const StyledPlaceholder = styled(Skeleton)(({ theme }) => ({
@@ -35,42 +35,45 @@ export function AttributionPanel({
return (
- { requiredStatement && (
-
+ {requiredStatement && (
+
+ )}
+ {rights && rights.length > 0 && (
+
+
+ {t("rights")}
+
+ {rights.map((v) => (
+
+
+ {v}
+
+
+ ))}
+
)}
- {
- rights && rights.length > 0 && (
-
- {t('rights')}
- { rights.map(v => (
-
-
- {v}
-
-
- )) }
-
- )
- }
- { manifestLogo && (
-
-
- }
- />
-
+ {manifestLogo && (
+
+
+ }
+ />
+
)}
@@ -81,10 +84,12 @@ export function AttributionPanel({
AttributionPanel.propTypes = {
id: PropTypes.string.isRequired,
manifestLogo: PropTypes.string,
- requiredStatement: PropTypes.arrayOf(PropTypes.shape({
- label: PropTypes.string,
- value: PropTypes.string,
- })),
+ requiredStatement: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ value: PropTypes.string,
+ }),
+ ),
rights: PropTypes.arrayOf(PropTypes.string),
windowId: PropTypes.string.isRequired,
};
diff --git a/src/components/ImageWithFallback.jsx b/src/components/ImageWithFallback.jsx
new file mode 100644
index 000000000..c27954935
--- /dev/null
+++ b/src/components/ImageWithFallback.jsx
@@ -0,0 +1,24 @@
+import { useState } from "react";
+import PropTypes from "prop-types";
+
+/**
+ * A plain img element that renders a fallback node when the image fails to load.
+ */
+export function ImageWithFallback({ alt, fallback, src, ...props }) {
+ const [hasError, setHasError] = useState(false);
+
+ if (hasError) return fallback;
+
+ return (
+
setHasError(true)} {...props} />
+ );
+}
+
+ImageWithFallback.propTypes = {
+ /** Alt text for the underlying img element. */
+ alt: PropTypes.string.isRequired,
+ /** Node rendered in place of the image when it fails to load. */
+ fallback: PropTypes.node.isRequired,
+ /** URL of the image to display. */
+ src: PropTypes.string.isRequired,
+};
diff --git a/src/components/ManifestListItem.jsx b/src/components/ManifestListItem.jsx
index 66b45b1c1..ed20d5dde 100644
--- a/src/components/ManifestListItem.jsx
+++ b/src/components/ManifestListItem.jsx
@@ -1,75 +1,76 @@
-import { useEffect } from 'react';
-import PropTypes from 'prop-types';
-import { styled } from '@mui/material/styles';
-import ListItem from '@mui/material/ListItem';
-import ButtonBase from '@mui/material/ButtonBase';
-import Grid from '@mui/material/Grid';
-import Typography from '@mui/material/Typography';
-import Skeleton from '@mui/material/Skeleton';
-import { useTranslation } from 'react-i18next';
-import { Img } from 'react-image';
-import ManifestListItemError from '../containers/ManifestListItemError';
-import ns from '../config/css-ns';
+import { useEffect } from "react";
+import PropTypes from "prop-types";
+import { styled } from "@mui/material/styles";
+import ListItem from "@mui/material/ListItem";
+import ButtonBase from "@mui/material/ButtonBase";
+import Grid from "@mui/material/Grid";
+import Typography from "@mui/material/Typography";
+import Skeleton from "@mui/material/Skeleton";
+import { useTranslation } from "react-i18next";
+import ManifestListItemError from "../containers/ManifestListItemError";
+import { ImageWithFallback } from "./ImageWithFallback";
+import ns from "../config/css-ns";
-const Root = styled(ListItem, { name: 'ManifestListItem', slot: 'root' })(
+const Root = styled(ListItem, { name: "ManifestListItem", slot: "root" })(
({ ownerState, theme }) => ({
- '&:hover,&:focus-within': {
+ "&:hover,&:focus-within": {
backgroundColor: theme.palette.action.hover,
borderLeftColor: ownerState?.active
? theme.palette.primary.main
: theme.palette.action.hover,
},
- borderLeft: '4px solid',
+ borderLeft: "4px solid",
borderLeftColor: ownerState?.active
? theme.palette.primary.main
- : 'transparent',
+ : "transparent",
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
- [theme.breakpoints.up('sm')]: {
+ [theme.breakpoints.up("sm")]: {
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
},
}),
);
-const StyledThumbnail = styled(Img, {
- name: 'ManifestListItem',
- slot: 'thumbnail',
-})(({ theme }) => ({
- maxWidth: '100%',
- objectFit: 'contain',
+const StyledThumbnail = styled(ImageWithFallback, {
+ name: "ManifestListItem",
+ slot: "thumbnail",
+})(() => ({
+ maxWidth: "100%",
+ objectFit: "contain",
}));
-const StyledLogo = styled(Img, { name: 'ManifestListItem', slot: 'logo' })(
- ({ theme }) => ({
- height: '2.5rem',
- maxWidth: '100%',
- objectFit: 'contain',
- paddingRight: 1,
- }),
-);
+const StyledLogo = styled(ImageWithFallback, {
+ name: "ManifestListItem",
+ slot: "logo",
+})(() => ({
+ height: "2.5rem",
+ maxWidth: "100%",
+ objectFit: "contain",
+ paddingRight: 1,
+}));
/** */
const Placeholder = () => (
-
+
-
+
-
-
+
+
{
- if (!ready && !error && !isFetching && provider !== 'file') fetchManifest(manifestId);
+ if (!ready && !error && !isFetching && provider !== "file")
+ fetchManifest(manifestId);
}, [manifestId, provider, fetchManifest, ready, error, isFetching]);
const ownerState = arguments[0]; // eslint-disable-line prefer-rest-params
@@ -120,7 +122,7 @@ export function ManifestListItem({
ownerState={ownerState}
divider
selected={active}
- className={active ? 'active' : ''}
+ className={active ? "active" : ""}
data-manifestid={manifestId}
>
@@ -132,65 +134,76 @@ export function ManifestListItem({
{ready ? (
-
-
+
+
{thumbnail ? (
- )}
+ }
/>
) : (
)}
-
+
{isCollection && (
- {t(isMultipart ? 'multipartCollection' : 'collection')}
+ {t(isMultipart ? "multipartCollection" : "collection")}
)}
{title || manifestId}
@@ -200,29 +213,29 @@ export function ManifestListItem({
-
+
{provider}
- {t('numItems', { count: size, number: size })}
+ {t("numItems", { count: size, number: size })}
{manifestLogo && (
- )}
+ }
/>
)}