diff --git a/web/src/collection/components/FileBrowserPage/FileBrowserActions/FileBrowserActions.js b/web/src/collection/components/FileBrowserPage/FileBrowserActions/FileBrowserActions.js index 86200ca2..ce15fe91 100644 --- a/web/src/collection/components/FileBrowserPage/FileBrowserActions/FileBrowserActions.js +++ b/web/src/collection/components/FileBrowserPage/FileBrowserActions/FileBrowserActions.js @@ -6,10 +6,11 @@ import TuneIcon from "@material-ui/icons/Tune"; import AddMediaButton from "./AddMediaButton"; import ViewSelector from "./ViewSelector"; import SortSelector from "./SortSelector"; -import { View } from "./view"; import SquaredIconButton from "../../../../common/components/SquaredIconButton"; import { useIntl } from "react-intl"; import { FileSort } from "../../../state/FileSort"; +import { Badge } from "@material-ui/core"; +import FileListType from "../../../state/FileListType"; const useStyles = makeStyles((theme) => ({ actions: { @@ -37,13 +38,15 @@ const FileBrowserActions = React.forwardRef(function FingerprintViewActions( onToggleFilters, showFiltersControls, showFiltersRef, + activeFilters, className, + ...other } = props; const classes = useStyles(); const intl = useIntl(); return ( -
+
{showFilters && ( - - - + + + + + )}
); @@ -87,7 +92,7 @@ FileBrowserActions.propTypes = { FileSort.duplicates, ]), onSortChange: PropTypes.func, - view: PropTypes.oneOf([View.list, View.grid]), + view: PropTypes.oneOf([FileListType.linear, FileListType.grid]), /** * Callback for switching List or Grid view */ @@ -102,6 +107,10 @@ FileBrowserActions.propTypes = { * Reference to show filter button */ showFiltersRef: PropTypes.any, + /** + * Active filters count that should be displayed. + */ + activeFilters: PropTypes.number, className: PropTypes.string, }; diff --git a/web/src/collection/components/FileBrowserPage/FileBrowserActions/ViewSelector.js b/web/src/collection/components/FileBrowserPage/FileBrowserActions/ViewSelector.js index 3e2aca4a..8e4f7139 100644 --- a/web/src/collection/components/FileBrowserPage/FileBrowserActions/ViewSelector.js +++ b/web/src/collection/components/FileBrowserPage/FileBrowserActions/ViewSelector.js @@ -2,9 +2,9 @@ import React from "react"; import PropTypes from "prop-types"; import ListIcon from "@material-ui/icons/ViewStream"; import GridIcon from "@material-ui/icons/ViewModule"; -import { View } from "./view"; import { useIntl } from "react-intl"; import IconSelect from "../../../../common/components/IconSelect"; +import FileListType from "../../../state/FileListType"; function useMessages() { const intl = useIntl(); @@ -21,12 +21,12 @@ function ViewSelector(props) { return ( @@ -35,7 +35,7 @@ function ViewSelector(props) { } ViewSelector.propTypes = { - view: PropTypes.oneOf([View.list, View.grid]), + view: PropTypes.oneOf([FileListType.linear, FileListType.grid]), onChange: PropTypes.func, className: PropTypes.string, }; diff --git a/web/src/collection/components/FileBrowserPage/FileBrowserActions/index.js b/web/src/collection/components/FileBrowserPage/FileBrowserActions/index.js index 99496ca3..9e94b38d 100644 --- a/web/src/collection/components/FileBrowserPage/FileBrowserActions/index.js +++ b/web/src/collection/components/FileBrowserPage/FileBrowserActions/index.js @@ -1,2 +1 @@ export { default } from "./FileBrowserActions"; -export { View } from "./view"; diff --git a/web/src/collection/components/FileBrowserPage/FileBrowserActions/view.js b/web/src/collection/components/FileBrowserPage/FileBrowserActions/view.js deleted file mode 100644 index d5ea5908..00000000 --- a/web/src/collection/components/FileBrowserPage/FileBrowserActions/view.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Fingerprint list style: list or grid. - * @type {{grid: string, list: string}} - */ -export const View = { - list: "list", - grid: "grid", -}; diff --git a/web/src/collection/components/FileBrowserPage/FileBrowserPage.js b/web/src/collection/components/FileBrowserPage/FileBrowserPage.js index 5e9565fe..462e6f88 100644 --- a/web/src/collection/components/FileBrowserPage/FileBrowserPage.js +++ b/web/src/collection/components/FileBrowserPage/FileBrowserPage.js @@ -3,7 +3,7 @@ import clsx from "clsx"; import PropTypes from "prop-types"; import { makeStyles } from "@material-ui/styles"; import ExpandLessIcon from "@material-ui/icons/ExpandLess"; -import FileBrowserActions, { View } from "./FileBrowserActions"; +import FileBrowserActions from "./FileBrowserActions"; import FilterPane from "./FilterPane"; import SearchTextInput from "./SearchTextInput"; import CategorySelector from "./CategorySelector"; @@ -26,6 +26,8 @@ import { useHistory, useLocation } from "react-router-dom"; import { routes } from "../../../routing/routes"; import { useIntl } from "react-intl"; import { defaultFilters } from "../../state/reducers"; +import FileListType from "../../state/FileListType"; +import { changeFileListView } from "../../state/actions"; const useStyles = makeStyles((theme) => ({ container: { @@ -112,9 +114,9 @@ const useStyles = makeStyles((theme) => ({ function listComponent(view) { switch (view) { - case View.list: + case FileListType.linear: return FileLinearList; - case View.grid: + case FileListType.grid: return FileGridList; default: throw new Error(`Unsupported fingerprints view type: ${view}`); @@ -125,7 +127,6 @@ function FileBrowserPage(props) { const { className } = props; const classes = useStyles(); const [showFilters, setShowFilters] = useState(false); - const [view, setView] = useState(View.grid); const collState = useSelector(selectColl); const error = useSelector(selectError); const loading = useSelector(selectLoading); @@ -136,11 +137,13 @@ function FileBrowserPage(props) { const [top, setTop] = useState(true); const topRef = useRef(null); const history = useHistory(); + const view = collState.fileListType; const List = listComponent(view); const intl = useIntl(); const showFiltersRef = useRef(); const location = useLocation(); const keepFilters = location.state?.keepFilters; + const activeFilters = FilterPane.useActiveFilters(); useEffect(() => { if (!keepFilters || collState.neverLoaded) { @@ -174,6 +177,11 @@ function FileBrowserPage(props) { [filters] ); + const handleChangeView = useCallback( + (view) => dispatch(changeFileListView(view)), + [] + ); + const scrollTop = useCallback(() => scrollIntoView(topRef), [topRef]); return ( @@ -188,12 +196,13 @@ function FileBrowserPage(props) { sort={filters.sort} onSortChange={handleChangeSort} view={view} - onViewChange={setView} + onViewChange={handleChangeView} onAddMedia={() => console.log("On Add Media")} showFilters={!showFilters} onToggleFilters={handleToggleFilters} className={classes.actions} showFiltersRef={showFiltersRef} + activeFilters={activeFilters} />
@@ -213,13 +222,13 @@ function FileBrowserPage(props) {
diff --git a/web/src/collection/components/FileBrowserPage/FileLinearList/FileLinearListItem.js b/web/src/collection/components/FileBrowserPage/FileLinearList/FileLinearListItem.js index 970ff82e..ab4b64c1 100644 --- a/web/src/collection/components/FileBrowserPage/FileLinearList/FileLinearListItem.js +++ b/web/src/collection/components/FileBrowserPage/FileLinearList/FileLinearListItem.js @@ -61,6 +61,7 @@ const FileLinearListItem = React.memo(function FpLinearListItem(props) { button = false, highlight, onClick, + dense, className, ...other } = props; @@ -81,9 +82,9 @@ const FileLinearListItem = React.memo(function FpLinearListItem(props) { {medium && } - {large && } - {large && } - {large && } + {large && !dense && } + {large && !dense && } + {large && !dense && } @@ -93,10 +94,26 @@ const FileLinearListItem = React.memo(function FpLinearListItem(props) { }); FileLinearListItem.propTypes = { + /** + * File to be displayed + */ file: FileType.isRequired, + /** + * File name substring that should be highlighted. + */ highlight: PropTypes.string, + /** + * Handle item click action. + */ button: PropTypes.bool, + /** + * Handle item click. + */ onClick: PropTypes.func, + /** + * Use dense layout. + */ + dense: PropTypes.bool, className: PropTypes.string, }; diff --git a/web/src/collection/components/FileBrowserPage/FilterPane/ContentFilters.js b/web/src/collection/components/FileBrowserPage/FilterPane/ContentFilters.js index 7bdb30af..56504b01 100644 --- a/web/src/collection/components/FileBrowserPage/FilterPane/ContentFilters.js +++ b/web/src/collection/components/FileBrowserPage/FilterPane/ContentFilters.js @@ -4,6 +4,10 @@ import FilterList from "./FilterList"; import { useFilters } from "./useFilters"; import { useIntl } from "react-intl"; import RangeFilter from "./RangeFilter"; +import { useSelector } from "react-redux"; +import { selectFilters } from "../../../state/selectors"; +import objectDiff from "../../../../common/helpers/objectDiff"; +import { initialState } from "../../../state"; /** * Get i18n text @@ -17,6 +21,15 @@ function useMessages() { }; } +/** + * Get count of active filters. + */ +function useActiveFilters() { + const filters = useSelector(selectFilters); + const diff = objectDiff(filters, initialState.filters); + return Number(diff.length); +} + function ContentFilters(props) { const { className } = props; const messages = useMessages(); @@ -38,6 +51,11 @@ function ContentFilters(props) { ); } +/** + * Hook to get count of active filters. + */ +ContentFilters.useActiveFilters = useActiveFilters; + ContentFilters.propTypes = { className: PropTypes.string, }; diff --git a/web/src/collection/components/FileBrowserPage/FilterPane/FilterPane.js b/web/src/collection/components/FileBrowserPage/FilterPane/FilterPane.js index 3e0e2acd..41e176df 100644 --- a/web/src/collection/components/FileBrowserPage/FilterPane/FilterPane.js +++ b/web/src/collection/components/FileBrowserPage/FilterPane/FilterPane.js @@ -62,11 +62,20 @@ function getTabComponent(tab) { } } +/** + * Get total count of active filters managed by filter pane. + */ +function useActiveFilters() { + return ContentFilters.useActiveFilters() + MetadataFilters.useActiveFilters(); +} + function FilterPane(props) { const { onSave, onClose, className, ...other } = props; const classes = useStyles(); const messages = useMessages(); const [tab, setTab] = useState(Tab.content); + const contentFilters = ContentFilters.useActiveFilters(); + const metadataFilters = MetadataFilters.useActiveFilters(); const TabComponent = getTabComponent(tab); @@ -75,8 +84,18 @@ function FilterPane(props) {
- - + + @@ -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; +}