-
-
+
+
@@ -85,6 +104,11 @@ function FilterPane(props) {
);
}
+/**
+ * Hook to get total count of active filters managed by filter pane.
+ */
+FilterPane.useActiveFilters = useActiveFilters;
+
FilterPane.propTypes = {
onClose: PropTypes.func,
onSave: PropTypes.func,
diff --git a/web/src/collection/components/FileBrowserPage/FilterPane/FilterPaneHeader.js b/web/src/collection/components/FileBrowserPage/FilterPane/FilterPaneHeader.js
index 0b7dfe7e..ddba63cc 100644
--- a/web/src/collection/components/FileBrowserPage/FilterPane/FilterPaneHeader.js
+++ b/web/src/collection/components/FileBrowserPage/FilterPane/FilterPaneHeader.js
@@ -87,7 +87,13 @@ FilterPaneHeader.propTypes = {
* Autofocus header when shown
*/
autoFocus: PropTypes.bool,
+ /**
+ * Handle close button.
+ */
onClose: PropTypes.func,
+ /**
+ * Handle save preset button.
+ */
onSave: PropTypes.func,
className: PropTypes.string,
"aria-controls": PropTypes.string,
diff --git a/web/src/collection/components/FileBrowserPage/FilterPane/MetadataFilters.js b/web/src/collection/components/FileBrowserPage/FilterPane/MetadataFilters.js
index 321a7681..efa8dc0d 100644
--- a/web/src/collection/components/FileBrowserPage/FilterPane/MetadataFilters.js
+++ b/web/src/collection/components/FileBrowserPage/FilterPane/MetadataFilters.js
@@ -7,6 +7,10 @@ import FilterList from "./FilterList";
import DateRangeFilter from "./DateRangeFilter";
import BoolFilter from "./BoolFilter";
import { useIntl } from "react-intl";
+import { initialState } from "../../../state";
+import objectDiff from "../../../../common/helpers/objectDiff";
+import { useSelector } from "react-redux";
+import { selectFilters } from "../../../state/selectors";
/**
* Get i18n text.
@@ -23,6 +27,15 @@ function useMessages() {
};
}
+/**
+ * Get count of active filters.
+ */
+function useActiveFilters() {
+ const filters = useSelector(selectFilters);
+ const diff = objectDiff(filters, initialState.filters);
+ return diff.extensions + diff.date + diff.audio + diff.exif;
+}
+
function MetadataFilters(props) {
const { className } = props;
const [filters, setFilters] = useFilters();
@@ -75,6 +88,11 @@ function MetadataFilters(props) {
);
}
+/**
+ * Hook to retrieve active filters count.
+ */
+MetadataFilters.useActiveFilters = useActiveFilters;
+
MetadataFilters.propTypes = {
className: PropTypes.string,
};
diff --git a/web/src/collection/components/MatchGraph/D3Graph.js b/web/src/collection/components/MatchGraph/D3Graph.js
index 61f59c2c..53153be1 100644
--- a/web/src/collection/components/MatchGraph/D3Graph.js
+++ b/web/src/collection/components/MatchGraph/D3Graph.js
@@ -17,7 +17,7 @@ const defaultOptions = {
};
function edgeWidth(edge) {
- return Math.sqrt(50 * (1 - edge.distance));
+ return Math.sqrt(50 * (1 - 0.8 * edge.distance));
}
const colorScheme = {
@@ -126,21 +126,23 @@ export default class D3Graph {
)
.append("g");
- // Bind this for legacy context handling
- const self = this;
-
const links = svg
.append("g")
.attr("stroke", "#999")
.selectAll("line")
.data(this.links)
.join("line")
- .attr("stroke-opacity", (d) => 1 - d.distance)
+ .attr("stroke-opacity", (d) => 1 - 0.8 * d.distance)
.attr("opacity", 1.0)
- .attr("stroke-width", (d) => edgeWidth(d))
- .on("click", (_, edge) => {
- this.onClickEdge({ source: edge.source.id, target: edge.target.id });
- })
+ .attr("stroke-width", (d) => edgeWidth(d));
+
+ const hitBoxLinks = svg
+ .append("g")
+ .selectAll("line")
+ .data(this.links)
+ .join("line")
+ .attr("stroke-width", 10)
+ .attr("stroke", "rgba(0,0,0,0)")
.style("cursor", "pointer");
const nodes = svg
@@ -153,51 +155,11 @@ export default class D3Graph {
.attr("r", this.options.nodeRadius)
.attr("fill", color(colorScheme.normal))
.call(this._createDrag(this.simulation))
- .on("click", (_, node) => {
- this.onClickNode(node);
- })
.style("cursor", "pointer");
- // Define mouse hover listeners for links
- links
- .on("mouseenter", function (event, edge) {
- self.tracker = self.makeLinkTracker(this, edge);
- self.tracker.track(event);
- if (self.options.highlightHover) {
- nodes.attr("fill", linkHoverPainter(edge, colorScheme));
- links.attr("opacity", (ln) => (ln === edge ? 1.0 : 0.4));
- }
- })
- .on("mouseleave", function () {
- self.tracker = null;
- if (self.options.highlightHover) {
- nodes.attr("fill", color(colorScheme.normal));
- links.attr("opacity", 1.0);
- }
- });
-
- // Define mouse hover listeners for links
- nodes
- .on("mouseenter", function (event, node) {
- self.tracker = self.makeNodeTracker(this, node);
- self.tracker.track(event);
- if (self.options.highlightHover) {
- nodes.attr(
- "fill",
- nodeHoverPainter(node, self.adjacency, colorScheme)
- );
- links.attr("opacity", (ln) =>
- ln.source.id === node.id || ln.target.id === node.id ? 1.0 : 0.4
- );
- }
- })
- .on("mouseleave", function () {
- self.tracker = null;
- if (self.options.highlightHover) {
- nodes.attr("fill", color(colorScheme.normal));
- links.attr("opacity", 1.0);
- }
- });
+ this._hookNodeEvents(nodes, links);
+ this._hookLinksEvents(links, links, nodes);
+ this._hookLinksEvents(hitBoxLinks, links, nodes);
this.simulation.on("tick", () => {
links
@@ -206,6 +168,12 @@ export default class D3Graph {
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
+ hitBoxLinks
+ .attr("x1", (d) => d.source.x)
+ .attr("y1", (d) => d.source.y)
+ .attr("x2", (d) => d.target.x)
+ .attr("y2", (d) => d.target.y);
+
nodes.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
this.tracker?.track();
});
@@ -227,6 +195,61 @@ export default class D3Graph {
window.addEventListener("resize", this.updateSize);
}
+ _hookNodeEvents(nodes, links) {
+ const self = this;
+
+ // Define mouse hover listeners for nodes
+ nodes
+ .on("mouseenter", function (event, node) {
+ self.tracker = self.makeNodeTracker(this, node);
+ self.tracker.track(event);
+ if (self.options.highlightHover) {
+ nodes.attr(
+ "fill",
+ nodeHoverPainter(node, self.adjacency, colorScheme)
+ );
+ links.attr("opacity", (ln) =>
+ ln.source.id === node.id || ln.target.id === node.id ? 1.0 : 0.4
+ );
+ }
+ })
+ .on("mouseleave", function () {
+ self.tracker = null;
+ if (self.options.highlightHover) {
+ nodes.attr("fill", color(colorScheme.normal));
+ links.attr("opacity", 1.0);
+ }
+ })
+ .on("click", (_, node) => {
+ this.onClickNode(node);
+ });
+ }
+
+ _hookLinksEvents(targetLinks, displayLinks, nodes) {
+ const self = this;
+
+ // Define mouse hover listeners for links
+ targetLinks
+ .on("mouseenter", function (event, edge) {
+ self.tracker = self.makeLinkTracker(this, edge);
+ self.tracker.track(event);
+ if (self.options.highlightHover) {
+ nodes.attr("fill", linkHoverPainter(edge, colorScheme));
+ displayLinks.attr("opacity", (ln) => (ln === edge ? 1.0 : 0.4));
+ }
+ })
+ .on("mouseleave", function () {
+ self.tracker = null;
+ if (self.options.highlightHover) {
+ nodes.attr("fill", color(colorScheme.normal));
+ displayLinks.attr("opacity", 1.0);
+ }
+ })
+ .on("click", (_, edge) => {
+ this.onClickEdge({ source: edge.source.id, target: edge.target.id });
+ });
+ }
+
/**
* Define a drag behavior.
*
diff --git a/web/src/collection/state/FileListType.js b/web/src/collection/state/FileListType.js
new file mode 100644
index 00000000..398bb39d
--- /dev/null
+++ b/web/src/collection/state/FileListType.js
@@ -0,0 +1,13 @@
+/**
+ * Enum for file list types.
+ */
+const FileListType = {
+ grid: "grid",
+ linear: "linear",
+
+ values() {
+ return [this.grid, this.linear];
+ },
+};
+
+export default FileListType;
diff --git a/web/src/collection/state/actions.js b/web/src/collection/state/actions.js
index e006a239..e93507d1 100644
--- a/web/src/collection/state/actions.js
+++ b/web/src/collection/state/actions.js
@@ -1,3 +1,14 @@
+import FileListType from "./FileListType";
+
+export const ACTION_CHANGE_FILE_LIST_VIEW = "coll.CHANGE_FILE_LIST_VIEW";
+
+export function changeFileListView(view) {
+ if (FileListType.values().indexOf(view) === -1) {
+ throw new Error(`Unknown file list type: ${view}`);
+ }
+ return { type: ACTION_CHANGE_FILE_LIST_VIEW, view };
+}
+
export const ACTION_UPDATE_FILTERS = "coll.UPDATE_FILTERS";
export function updateFilters(filters) {
diff --git a/web/src/collection/state/reducers.js b/web/src/collection/state/reducers.js
index 06f791f5..ec64c722 100644
--- a/web/src/collection/state/reducers.js
+++ b/web/src/collection/state/reducers.js
@@ -1,5 +1,6 @@
import {
ACTION_CACHE_FILE,
+ ACTION_CHANGE_FILE_LIST_VIEW,
ACTION_FETCH_FILE_MATCHES,
ACTION_FETCH_FILE_MATCHES_FAILURE,
ACTION_FETCH_FILE_MATCHES_SUCCESS,
@@ -15,6 +16,7 @@ import {
} from "./actions";
import { MatchCategory } from "./MatchCategory";
import { FileSort } from "./FileSort";
+import FileListType from "./FileListType";
export const initialState = {
neverLoaded: true,
@@ -31,6 +33,7 @@ export const initialState = {
matches: MatchCategory.all,
sort: FileSort.date,
},
+ fileListType: FileListType.grid,
limit: 20,
counts: {
all: 0,
@@ -216,6 +219,14 @@ export function collRootReducer(state = initialState, action) {
...state,
fileCache: fileCacheReducer(state.fileCache, action),
};
+ case ACTION_CHANGE_FILE_LIST_VIEW:
+ if (FileListType.values().indexOf(action.view) === -1) {
+ throw new Error(`Unknown file list type: ${action.view}`);
+ }
+ return {
+ ...state,
+ fileListType: action.view,
+ };
case ACTION_UPDATE_FILE_MATCH_FILTERS:
case ACTION_UPDATE_FILE_MATCH_FILTERS_SUCCESS:
case ACTION_UPDATE_FILE_MATCH_FILTERS_FAILURE:
diff --git a/web/src/common/components/SelectableTabs/SelectableTab.js b/web/src/common/components/SelectableTabs/SelectableTab.js
index f48d5366..2dce0ad5 100644
--- a/web/src/common/components/SelectableTabs/SelectableTab.js
+++ b/web/src/common/components/SelectableTabs/SelectableTab.js
@@ -2,36 +2,33 @@ import React, { useCallback } from "react";
import clsx from "clsx";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/styles";
-import SelectionDecorator from "../SelectionDecorator";
import { ButtonBase } from "@material-ui/core";
+import Badge from "@material-ui/core/Badge";
const useStyles = makeStyles((theme) => ({
tab: {
cursor: "pointer",
-
- /**
- * Ensure selection decorator is displayed correctly.
- */
- transform: "translate(0%, 0px)",
+ borderBottom: `3px solid rgba(0,0,0,0)`,
+ paddingBottom: theme.spacing(0.5),
},
sizeLarge: {
...theme.mixins.navlinkLarge,
fontWeight: 500,
- marginBottom: theme.spacing(1),
},
sizeMedium: {
...theme.mixins.navlink,
fontWeight: 500,
- marginBottom: theme.spacing(1),
},
sizeSmall: {
...theme.mixins.navlinkSmall,
fontWeight: 500,
- marginBottom: theme.spacing(0.5),
},
inactive: {
color: theme.palette.action.textInactive,
},
+ selected: {
+ borderBottom: `3px solid ${theme.palette.primary.main}`,
+ },
}));
/**
@@ -57,6 +54,9 @@ function SelectableTab(props) {
value,
size = "medium",
className,
+ badge,
+ badgeMax,
+ badgeColor = "default",
...other
} = props;
const classes = useStyles();
@@ -65,7 +65,7 @@ function SelectableTab(props) {
return (
- {label}
- {selected && }
+
+ {label}
+
);
}
@@ -100,6 +101,18 @@ SelectableTab.propTypes = {
* Size variants
*/
size: PropTypes.oneOf(["small", "medium", "large"]),
+ /**
+ * The value displayed with the optional badge.
+ */
+ badge: PropTypes.node,
+ /**
+ * The color of the component. It supports those theme colors that make sense for this component.
+ */
+ badgeColor: PropTypes.oneOf(["default", "error", "primary", "secondary"]),
+ /**
+ * Max count to show in badge (if the value is numeric).
+ */
+ badgeMax: PropTypes.number,
className: PropTypes.string,
};
diff --git a/web/src/common/helpers/objectDiff.js b/web/src/common/helpers/objectDiff.js
new file mode 100644
index 00000000..fe5e32e5
--- /dev/null
+++ b/web/src/common/helpers/objectDiff.js
@@ -0,0 +1,26 @@
+import lodash from "lodash";
+
+/**
+ * Get keys of multiple objects.
+ * @param objects any list of plain objects.
+ * @returns {Set
}
+ */
+function keys(...objects) {
+ const result = new Set();
+ for (const object of objects) {
+ Object.keys(object).forEach(result.add, result);
+ }
+ return result;
+}
+
+/**
+ * Compare two plain objects, perform deep comparison on each attribute and
+ * return a new object where for each attribute there is a comparison result.
+ */
+export default function objectDiff(object, otherObject) {
+ const result = {};
+ for (const key of keys(object, otherObject)) {
+ result[key] = !lodash.isEqual(object[key], otherObject[key]);
+ }
+ return result;
+}