diff --git a/package.json b/package.json index bffb37564..0724257fa 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "webpack-cli": "^3.2.0" }, "dependencies": { - "@blueprintjs/core": "^3.12.0", - "@blueprintjs/select": "^3.6.0", + "@blueprintjs/core": "^3.15.0", + "@blueprintjs/select": "^3.8.0", "fs-extra": "^7.0.1", "idb": "^3.0.2", "mobx": "^5.9.0", diff --git a/src/renderer/frontend/App.tsx b/src/renderer/frontend/App.tsx index b2bc91a43..cec613498 100644 --- a/src/renderer/frontend/App.tsx +++ b/src/renderer/frontend/App.tsx @@ -1,42 +1,31 @@ -import { Breadcrumbs, IBreadcrumbProps, InputGroup } from '@blueprintjs/core'; import { observer } from 'mobx-react-lite'; import React from 'react'; import FileList from './components/FileList'; -import Sidebar from './components/Sidebar'; -import { withRootstore, IRootStoreProp } from './contexts/StoreContext'; +import Outliner from './components/Outliner'; +import { IRootStoreProp, withRootstore } from './contexts/StoreContext'; +import Inspector from './components/Inspector'; +import Toolbar from './components/Toolbar'; +import ErrorBoundary from './components/ErrorBoundary'; interface IAppProps extends IRootStoreProp {} const App = ({ rootStore: { uiStore } }: IAppProps) => { - // Breadcrumbs placeholder - const breadcrumbs: IBreadcrumbProps[] = [ - { icon: 'symbol-square' }, - { icon: 'folder-close', text: 'Cars' }, - { icon: 'folder-close', text: 'Yellow' }, - { icon: 'document', text: 'New' }, - ]; - const themeClass = uiStore.theme === 'DARK' ? 'bp3-dark' : 'bp3-light'; return ( -
- - -
-
- +
+ + - {/* This can be replaced with the custom SearchBar component later */} - -
+ -
- -
+
-
-
+ + + +
); }; diff --git a/src/renderer/frontend/components/ErrorBoundary.tsx b/src/renderer/frontend/components/ErrorBoundary.tsx new file mode 100644 index 000000000..97366d532 --- /dev/null +++ b/src/renderer/frontend/components/ErrorBoundary.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { remote } from 'electron'; +import { Button, NonIdealState, ButtonGroup, EditableText } from '@blueprintjs/core'; + +interface IErrorBoundaryState { + hasError: boolean; + error: string; +} + +class ErrorBoundary extends React.Component<{}, IErrorBoundaryState> { + static getDerivedStateFromError(error: any) { + // Update state so the next render will show the fallback UI. + return { hasError: true, error }; + } + state = { + hasError: false, + error: '', + }; + + componentDidCatch(error: any, info: any) { + // TODO: Send error to logging service + const stringifiedError = JSON.stringify(error, Object.getOwnPropertyNames(error), 2); + this.setState({ error: stringifiedError }); + } + + viewInspector() { + remote.getCurrentWebContents() + .openDevTools(); + } + + reloadApplication() { + remote.getCurrentWindow() + .reload(); + } + + render() { + const { hasError, error } = this.state; + if (hasError) { + // You can render any custom fallback UI + return ( +
+ 😞} + title="Something went wrong." + description="You can try one of the following options or contact the maintainers" + action={ + + + + } + > + + +
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/renderer/frontend/components/FileInfo.tsx b/src/renderer/frontend/components/FileInfo.tsx new file mode 100644 index 000000000..c110db47f --- /dev/null +++ b/src/renderer/frontend/components/FileInfo.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import fs from 'fs'; +import { observer } from 'mobx-react-lite'; + +import { ClientFile } from '../../entities/File'; + +const formatDate = (d: Date) => + `${d.getUTCFullYear()}-${d.getUTCMonth() + + 1}-${d.getUTCDate()} ${d.getUTCHours()}:${d.getUTCMinutes()}`; + +interface IFileInfoProps { + files: ClientFile[]; +} + +const SingleFileInfo = observer(({ file }: { file: ClientFile }) => { + const [fileStats, setFileStats] = useState(undefined); + const [error, setError] = useState(undefined); + + // Look up file info when file changes + useEffect( + () => { + fs.stat(file.path, (err, stats) => + err ? setError(err) : setFileStats(stats), + ); + }, + [file], + ); + + // Todo: Would be nice to also add tooltips explaining what these mean (e.g. diff between dimensions & resolution) + // Or add the units: pixels vs DPI + const fileInfoList = useMemo( + () => [ + { key: 'Filename', value: file.path }, + { + key: 'Created', + value: fileStats ? formatDate(fileStats.birthtime) : '...', + }, + { key: 'Modified', value: fileStats ? formatDate(fileStats.ctime) : '...' }, + { + key: 'Last Opened', + value: fileStats ? formatDate(fileStats.atime) : '...', + }, + { key: 'Dimensions', value: '?' }, + { key: 'Resolution', value: '?' }, + { key: 'Color Space', value: '?' }, + ], + [file, fileStats], + ); + + return ( +
+ {fileInfoList.map(({ key, value }) => [ +
+ {key} +
, +
+ {value} +
, + ])} + + {error && ( +

+ Error: {error.name}
{error.message} +

+ )} +
+ ); +}); + +const MultiFileInfo = observer(({ files }: IFileInfoProps) => { + return ( +
+

Selected {files.length} files

+
+ ); +}); + +const FileInfo = ({ files }: IFileInfoProps) => { + if (files.length === 1) { + return ; + } else { + return ; + } +}; + +export default FileInfo; diff --git a/src/renderer/frontend/components/FileList.tsx b/src/renderer/frontend/components/FileList.tsx index 8286a03fa..faf4f5556 100644 --- a/src/renderer/frontend/components/FileList.tsx +++ b/src/renderer/frontend/components/FileList.tsx @@ -1,70 +1,39 @@ -import { remote } from 'electron'; -import fse from 'fs-extra'; -import path from 'path'; -import React from 'react'; - +import React, { useCallback } from 'react'; import { observer } from 'mobx-react-lite'; -import { Button } from '@blueprintjs/core'; import { withRootstore, IRootStoreProp } from '../contexts/StoreContext'; -import FileStore from '../stores/FileStore'; - import Gallery from './Gallery'; -import FileSelectionHeader from './FileSelectionHeader'; +import { Tag, ITagProps } from '@blueprintjs/core'; export interface IFileListProps extends IRootStoreProp {} -const chooseDirectory = async (fileStore: FileStore) => { - const dirs = remote.dialog.showOpenDialog({ - properties: ['openDirectory', 'multiSelections'], - }); - - if (!dirs) { - return; - } - - dirs.forEach(async (dir) => { - // Check if directory - // const stats = await fse.lstat(dirs[0]); - const imgExtensions = ['gif', 'png', 'jpg', 'jpeg']; - - const filenames = await fse.readdir(dir); - const imgFileNames = filenames.filter((f) => - imgExtensions.some((ext) => - f.toLowerCase() - .endsWith(ext)), - ); +const FileList = ({ rootStore: { uiStore, fileStore, tagStore } }: IFileListProps) => { - imgFileNames.forEach(async (filename) => { - const joinedPath = path.join(dir, filename); - console.log(joinedPath); - fileStore.addFile(joinedPath); - }); - }); -}; - -const FileList = ({ rootStore: { uiStore, fileStore } }: IFileListProps) => { - const removeSelectedFiles = async () => { - await fileStore.removeFilesById(uiStore.fileSelection); - uiStore.fileSelection.clear(); - }; + const handleDeselectTag = useCallback( + (_, props: ITagProps) => { + const clickedTag = tagStore.tagList.find((t) => t.id === props.id); + if (clickedTag) { + uiStore.deselectTag(clickedTag); + } + }, + [], + ); return ( -
- {uiStore.fileSelection.length > 0 && ( - uiStore.fileSelection.clear()} - onRemove={removeSelectedFiles} - /> - )} - - - -
-
+
+ +
+ {uiStore.clientTagSelection.map((tag) => ( + + {tag.name} + ), + )} +
diff --git a/src/renderer/frontend/components/FileSelectionHeader.tsx b/src/renderer/frontend/components/FileSelectionHeader.tsx deleted file mode 100644 index 4d1b26666..000000000 --- a/src/renderer/frontend/components/FileSelectionHeader.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import { Icon, Popover, Button, Classes, Intent, H5 } from '@blueprintjs/core'; - -interface IFileSelectionHeaderProps { - numSelectedFiles: number; - onCancel: () => void; - onRemove: () => void; -} - -const FileSelectionHeader = ({ - numSelectedFiles, - onCancel, - onRemove, -}: IFileSelectionHeaderProps) => { - return ( -
- - - {numSelectedFiles} image{numSelectedFiles > 1 ? 's' : ''} selected - - - -
-
Confirm deletion
-

Are you sure you want to remove these images from your library?

-

Your files will not be deleted.

- -
- - -
-
-
-
- ); -}; - -export default FileSelectionHeader; diff --git a/src/renderer/frontend/components/FileTag.tsx b/src/renderer/frontend/components/FileTag.tsx new file mode 100644 index 000000000..e632c87e0 --- /dev/null +++ b/src/renderer/frontend/components/FileTag.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useContext } from 'react'; +import { ClientTag } from '../../entities/Tag'; +import { ClientFile } from '../../entities/File'; +import { observer } from 'mobx-react-lite'; +import MultiTagSelector from './MultiTagSelector'; +import StoreContext from '../contexts/StoreContext'; + +interface IFileTagProps { + files: ClientFile[]; +} + +const Single = observer(({ file }: { file: ClientFile }) => { + const { tagStore } = useContext(StoreContext); + + const handleClear = useCallback(() => file.tags.clear(), [file]); + + const handleDeselect = useCallback((index) => file.tags.splice(index, 1), [ + file, + ]); + + const handleSelect = useCallback((tag) => file.tags.push(tag.id), [file]); + + const handleCreate = useCallback( + (name: string) => { + const newTag = tagStore.addTag(name); + // Todo: When merging with hierarchy, make sure to this tag to a collection + return newTag; + }, + [file], + ); + + return ( + + ); +}); + +const Multi = observer(({ files }: IFileTagProps) => { + const { tagStore } = useContext(StoreContext); + + // Count how often tags are used + const combinedTags: ClientTag[] = []; + files.forEach((f) => combinedTags.push(...f.clientTags)); + const countMap = new Map(); + combinedTags.forEach((t) => countMap.set(t, (countMap.get(t) || 0) + 1)); + + // Sort based on count + const sortedTags = Array.from(countMap.entries()).sort((a, b) => b[1] - a[1]); + + const handleClear = useCallback(() => files.forEach((f) => f.tags.clear()), [ + files, + ]); + + const handleSelect = useCallback( + (tag: ClientTag) => files.forEach((f) => f.tags.push(tag.id)), + [files], + ); + + const handleDeselect = useCallback( + (index: number) => { + const removedTag = sortedTags[index][0]; + files.forEach((f) => f.tags.remove(removedTag.id)); + }, + [files, sortedTags], + ); + + const tagLabel = useCallback( + (tag: ClientTag) => { + const match = sortedTags.find((pair) => pair[0] === tag); + return `${tag.name} (${match ? match[1] : '?'})`; + }, + [sortedTags], + ); + + const handleCreate = useCallback( + (name: string) => { + const newTag = tagStore.addTag(name); + files.forEach((file) => file.addTag(newTag.id)); + return newTag; + }, + [files], + ); + + return ( + pair[0])} + onClearSelection={handleClear} + onTagDeselect={handleDeselect} + onTagSelect={handleSelect} + tagLabel={tagLabel} + onTagCreation={handleCreate} + /> + ); +}); + +const FileTag = ({ files }: IFileTagProps) => { + return ( +
+
Tags
+ {files.length === 1 ? ( + + ) : ( + + )} +
+ ); +}; + +export default FileTag; diff --git a/src/renderer/frontend/components/Gallery.tsx b/src/renderer/frontend/components/Gallery.tsx index cbab7fc58..163a736e6 100644 --- a/src/renderer/frontend/components/Gallery.tsx +++ b/src/renderer/frontend/components/Gallery.tsx @@ -1,11 +1,9 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { observer } from 'mobx-react-lite'; import { withRootstore, IRootStoreProp } from '../contexts/StoreContext'; import GalleryItem from './GalleryItem'; -import { ClientFile } from '../../entities/File'; -import { ClientTag } from '../../entities/Tag'; interface IGalleryProps extends IRootStoreProp {} @@ -15,18 +13,82 @@ const Gallery = ({ fileStore: { fileList }, }, }: IGalleryProps) => { + // Todo: Maybe move these to UiStore so that it can be reset when the fileList changes? + /** The first item that is selected in a multi-selection */ + const [initialSelectionIndex, setInitialSelectionIndex] = useState< + number | undefined + >(undefined); + /** The last item that is selected in a multi-selection */ + const [lastSelectionIndex, setLastSelectionIndex] = useState< + number | undefined + >(undefined); + + const selectionModeOn = uiStore.fileSelection.length > 0; + + const onSelect = (i: number, e: React.MouseEvent) => { + if (e.shiftKey) { + // Shift selection: Select from the initial up to the current index + if (initialSelectionIndex !== undefined) { + uiStore.fileSelection.clear(); + // Make sure that sliceStart is the lowest index of the two and vice versa + let sliceStart = initialSelectionIndex; + let sliceEnd = i; + if (i < initialSelectionIndex) { + sliceStart = i; + sliceEnd = initialSelectionIndex; + } + uiStore.fileSelection.push(...fileList.slice(sliceStart, sliceEnd + 1) + .map((f) => f.id)); + } + } else if (e.ctrlKey || e.metaKey) { + // Ctrl/meta selection: Add this file to selection + setInitialSelectionIndex(i); + uiStore.fileSelection.push(fileList[i].id); + } else { + // Normal selection: Only select this file + setInitialSelectionIndex(i); + uiStore.fileSelection.clear(); + uiStore.fileSelection.push(fileList[i].id); + } + setLastSelectionIndex(i); + }; + + const onKeyDown = (e: KeyboardEvent) => { + // When an arrow key is pressed, select the item relative to the last selected item + // Fixme: For some reason, the state is not updated here (lastSelectionIndex is always undefined) + // console.log(e, lastSelectionIndex); + if (lastSelectionIndex === undefined) { + return; + } + if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + uiStore.fileSelection.clear(); + uiStore.selectFile(fileList[Math.max(0, lastSelectionIndex - 1)]); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + uiStore.fileSelection.clear(); + uiStore.selectFile( + fileList[Math.min(fileList.length - 1, lastSelectionIndex + 1)], + ); + } + }; + + useEffect(() => { + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, []); + return ( -
- {fileList.map((file) => ( +
+ {fileList.map((file, fileIndex) => ( file.removeTag(tag.id)} - onSelect={(f: ClientFile) => uiStore.selectFile(f)} - onOpen={(f) => console.log('Open file ', f)} - onDeselect={(f: ClientFile) => uiStore.deselectFile(f)} - onDrop={(tag: ClientTag) => file.addTag(tag.id)} + onRemoveTag={(tag) => file.removeTag(tag.id)} + onSelect={(f, e) => onSelect(fileIndex, e)} + onDeselect={(f) => uiStore.deselectFile(f)} + onDrop={(tag) => file.addTag(tag.id)} /> ))}
diff --git a/src/renderer/frontend/components/GalleryItem.tsx b/src/renderer/frontend/components/GalleryItem.tsx index df70e994a..d250a0fdb 100644 --- a/src/renderer/frontend/components/GalleryItem.tsx +++ b/src/renderer/frontend/components/GalleryItem.tsx @@ -8,7 +8,6 @@ import { DropTarget, ConnectDropTarget, DropTargetMonitor } from 'react-dnd'; import { ClientFile } from '../../entities/File'; import { Tag, - Icon, ContextMenuTarget, Menu, MenuItem, @@ -30,9 +29,8 @@ interface IGalleryItemProps { file: ClientFile; isSelected: boolean; onRemoveTag: (tag: ClientTag) => void; - onSelect: (file: ClientFile) => void; - onOpen: (file: ClientFile) => void; - onDeselect: (file: ClientFile) => void; + onSelect: (file: ClientFile, e: React.MouseEvent) => void; + onDeselect: (file: ClientFile, e: React.MouseEvent) => void; onDrop: (item: any) => void; } @@ -47,7 +45,6 @@ const GalleryItem = ({ isSelected, onRemoveTag, onSelect, - onOpen, onDeselect, canDrop, isOver, @@ -58,12 +55,15 @@ const GalleryItem = ({ const className = `thumbnail ${selectedStyle} ${isOver ? dropStyle : ''}`; + // Switch between opening/selecting depending on whether the selection mode is enabled + const clickFunc = isSelected ? onDeselect : onSelect; + return connectDropTarget(
onOpen(file)} + onClick={(e) => clickFunc(file, e)} /> {file.clientTags.map((tag) => ( @@ -74,11 +74,6 @@ const GalleryItem = ({ /> ))} -
(isSelected ? onDeselect(file) : onSelect(file))}> - -
, ); }; diff --git a/src/renderer/frontend/components/ImportForm.tsx b/src/renderer/frontend/components/ImportForm.tsx new file mode 100644 index 000000000..95c4e749d --- /dev/null +++ b/src/renderer/frontend/components/ImportForm.tsx @@ -0,0 +1,71 @@ +import React, { useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useContext } from 'react'; +import { remote } from 'electron'; +import fse from 'fs-extra'; +import path from 'path'; + +import StoreContext from '../contexts/StoreContext'; +import { Button } from '@blueprintjs/core'; +import FileStore from '../stores/FileStore'; + +const chooseDirectory = async (fileStore: FileStore) => { + const dirs = remote.dialog.showOpenDialog({ + properties: ['openDirectory', 'multiSelections'], + }); + + if (!dirs) { + return; + } + + dirs.forEach(async (dir) => { + // Check if directory + // const stats = await fse.lstat(dirs[0]); + const imgExtensions = ['gif', 'png', 'jpg', 'jpeg']; + + const filenames = await fse.readdir(dir); + const imgFileNames = filenames.filter((f) => + imgExtensions.some((ext) => + f.toLowerCase() + .endsWith(ext)), + ); + + imgFileNames.forEach(async (filename) => { + const joinedPath = path.join(dir, filename); + console.log(joinedPath); + fileStore.addFile(joinedPath); + }); + }); +}; + +const ImportForm = () => { + // Todo: Add Location entity to DB, so we can have user-picked directories as well + // Todo: Also show sub-directories in tree + + const { fileStore } = useContext(StoreContext); + + const handleChooseDirectory = useCallback( + () => chooseDirectory(fileStore), + [], + ); + + return ( + <> + + + + + + + {/* Todo: Show progress bar here */} + + ); +}; + +export default observer(ImportForm); diff --git a/src/renderer/frontend/components/Inspector.tsx b/src/renderer/frontend/components/Inspector.tsx new file mode 100644 index 000000000..9eefd176d --- /dev/null +++ b/src/renderer/frontend/components/Inspector.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import fs from 'fs'; +import path from 'path'; +import { observer } from 'mobx-react-lite'; + +import RootStore from '../stores/RootStore'; +import { withRootstore } from '../contexts/StoreContext'; +import FileInfo from './FileInfo'; +import FileTag from './FileTag'; + +const sufixes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; +const getBytes = (bytes: number) => { + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return !bytes && '0 Bytes' || (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sufixes[i]; +}; + +interface IInspectorProps { + rootStore: RootStore; +} + +const Inspector = ({ rootStore: { uiStore } }: IInspectorProps) => { + const selectedFiles = uiStore.clientFileSelection; + + let selectionPreview; + let headerText; + let headerSubtext; + + if (selectedFiles.length === 0) { + headerText = 'No image selected'; + } else if (selectedFiles.length === 1) { + const singleFile = selectedFiles[0]; + const ext = singleFile.path.substr(singleFile.path.lastIndexOf('.') + 1) + .toUpperCase(); + selectionPreview = ; + headerText = path.basename(singleFile.path); + headerSubtext = `${ext} image - ${getBytes(fs.statSync(singleFile.path).size)}`; + } else { + // Todo: fs.stat (not sync) is preferred, but it seems to execute instantly... good enough for now + let size = 0; + selectedFiles.forEach((f) => size += fs.statSync(f.path).size); + + selectionPreview =

Carousel of selected images here?

; + headerText = selectedFiles.map((f) => path.basename(f.path)) + .join(', '); + headerSubtext = getBytes(size); + } + + if (selectedFiles.length > 0) { + return ( + + ); + } else { + return ( + + ); + } +}; + +export default withRootstore(observer(Inspector)); diff --git a/src/renderer/frontend/components/MultiTagSelector.tsx b/src/renderer/frontend/components/MultiTagSelector.tsx new file mode 100644 index 000000000..8f2ca562c --- /dev/null +++ b/src/renderer/frontend/components/MultiTagSelector.tsx @@ -0,0 +1,147 @@ +import React, { useContext, useMemo, useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; + +import { Button, MenuItem } from '@blueprintjs/core'; +import { ItemRenderer, MultiSelect, ItemPredicate } from '@blueprintjs/select'; + +import { ClientTag } from '../../entities/Tag'; +import StoreContext from '../contexts/StoreContext'; + +const TagMultiSelect = MultiSelect.ofType(); + +const NoResults = ; + +const CREATED_TAG_ID = 'created-tag-id'; + +const renderCreateTagOption = ( + query: string, + active: boolean, + handleClick: React.MouseEventHandler, +) => ( + +); + +const filterTag: ItemPredicate = (query, tag, index, exactMatch) => { + const normalizedName = tag.name.toLowerCase(); + const normalizedQuery = query.toLowerCase(); + + if (exactMatch) { + return normalizedName === normalizedQuery; + } else { + return normalizedName.indexOf(normalizedQuery) >= 0; + } +}; + +interface IMultiTagSelectorProps { + selectedTags: ClientTag[]; + tagLabel?: (tag: ClientTag) => string; + onTagSelect: (tag: ClientTag) => void; + onTagDeselect: (index: number) => void; + onClearSelection: () => void; + onTagCreation?: (name: string) => ClientTag; +} + +const MultiTagSelector = ({ + selectedTags, + tagLabel, + onTagSelect, + onTagDeselect, + onClearSelection, + onTagCreation, +}: IMultiTagSelectorProps) => { + const { tagStore } = useContext(StoreContext); + + const handleSelect = useCallback( + (tag: ClientTag) => { + // When a tag is created, it is selected. Here we detect whether we need to actually the ClientTag. + if (onTagCreation && tag.id === CREATED_TAG_ID) { + tag = onTagCreation(tag.name); + } + + return selectedTags.includes(tag) + ? onTagDeselect(selectedTags.indexOf(tag)) + : onTagSelect(tag); + }, + [selectedTags], + ); + + const handleDeselect = useCallback( + (_: string, index: number) => onTagDeselect(index), + [onTagDeselect], + ); + + // Todo: Might need a confirmation pop over + const ClearButton = useMemo( + () => + selectedTags.length > 0 ? ( + + + ); +}; + +export default observer(SearchForm); diff --git a/src/renderer/frontend/components/Sidebar.tsx b/src/renderer/frontend/components/Sidebar.tsx deleted file mode 100644 index d566f21d9..000000000 --- a/src/renderer/frontend/components/Sidebar.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import TagList from './TagList'; - -const Sidebar = () => ( -
-

All tags

- -
-); - -export default Sidebar; diff --git a/src/renderer/frontend/components/TagList.tsx b/src/renderer/frontend/components/TagList.tsx index 8240fa698..135a60ef3 100644 --- a/src/renderer/frontend/components/TagList.tsx +++ b/src/renderer/frontend/components/TagList.tsx @@ -24,7 +24,6 @@ const TagList = ({ } else { uiStore.selectTag(tag); } - fileStore.fetchFilesByTagIDs(uiStore.tagSelection.toJS()); }; return ( diff --git a/src/renderer/frontend/components/Toolbar.tsx b/src/renderer/frontend/components/Toolbar.tsx new file mode 100644 index 000000000..7806120a8 --- /dev/null +++ b/src/renderer/frontend/components/Toolbar.tsx @@ -0,0 +1,184 @@ +import React, { useContext, useCallback, useMemo } from 'react'; +import { + Button, Popover, MenuItem, Menu, Drawer, Switch, ButtonGroup, Icon, Divider, Classes, H5, +} from '@blueprintjs/core'; +import { observer } from 'mobx-react-lite'; + +import StoreContext from '../contexts/StoreContext'; + +const RemoveFilesPopover = ({ onRemove, disabled }: { onRemove: () => void, disabled: boolean }) => ( + + + +
+
+ +); + +const Toolbar = () => { + const { uiStore, fileStore } = useContext(StoreContext); + + // Outliner actions + const handleOutlinerImport = useCallback(() => { uiStore.outlinerPage = 'IMPORT'; }, []); + const handleOutlinerTags = useCallback(() => { uiStore.outlinerPage = 'TAGS'; }, []); + const handleOutlinerSearch = useCallback(() => { uiStore.outlinerPage = 'SEARCH'; }, []); + + // Content actions + const isFileListSelected = uiStore.fileSelection.length === fileStore.fileList.length; + // If everything is selected, deselect all. Else, select all + const handleToggleSelect = useCallback( + () => (isFileListSelected) + ? uiStore.fileSelection.clear() + : uiStore.fileSelection.push( + ...fileStore.fileList + .map((f) => f.id) + .filter((f) => !uiStore.fileSelection.includes(f)), + ), + [isFileListSelected], + ); + + const handleRemoveSelectedFiles = useCallback( + async () => { + await fileStore.removeFilesById(uiStore.fileSelection); + uiStore.fileSelection.clear(); + }, + [], + ); + + // Inspector actions + const handleToggleInspector = useCallback( + () => { uiStore.isInspectorOpen = !uiStore.isInspectorOpen; }, + [], + ); + + const handleToggleSettings = useCallback( + () => { uiStore.isSettingsOpen = !uiStore.isSettingsOpen; }, + [], + ); + + // Settings actions + const toggleTheme = useCallback( + () => { uiStore.theme = (uiStore.theme === 'DARK' ? 'LIGHT' : 'DARK'); }, + [], + ); + + // Render variables + const sortMenu = useMemo(() => + + + + + + } active /> + , + [], + ); + + const layoutMenu = useMemo(() => + + + + + + , + [], + ); + + const selectionModeOn = uiStore.fileSelection.length > 0; + const olPage = uiStore.outlinerPage; + + return ( +
+
+ +
+ +
+ + {/* Library info. Todo: Show entire library count instead of current fileList */} + + + + + {/* Selection info and actions */} + + {/* Todo: Show popover for modifying tags of selection (same as inspector?) */} +
+ +
+ + + + +
+
+ ); +}; + +export default observer(Toolbar); diff --git a/src/renderer/frontend/stores/FileStore.ts b/src/renderer/frontend/stores/FileStore.ts index 8d1f5fc59..9c66ca73a 100644 --- a/src/renderer/frontend/stores/FileStore.ts +++ b/src/renderer/frontend/stores/FileStore.ts @@ -1,6 +1,6 @@ import { action, observable } from 'mobx'; - import fs from 'fs-extra'; + import Backend from '../../backend/Backend'; import { ClientFile, IFile } from '../../entities/File'; import RootStore from './RootStore'; @@ -35,16 +35,14 @@ class FileStore { @action async removeFilesById(ids: ID[]) { await Promise.all( - ids.map( - async (id) => { - const file = this.fileList.find((f) => f.id === id); - if (file) { - await this.removeFile(file); - } else { - console.log('Could not find file to remove', file); - } - }, - ), + ids.map(async (id) => { + const file = this.fileList.find((f) => f.id === id); + if (file) { + await this.removeFile(file); + } else { + console.log('Could not find file to remove', file); + } + }), ); } @@ -79,24 +77,25 @@ class FileStore { // Removes files with invalid file path. Otherwise adds files to fileList. // In the future the user should have the option to input the new path if the file was only moved or renamed. await Promise.all( - fetchedFiles.map( - async (backendFile: IFile) => { - try { - await fs.access(backendFile.path, fs.constants.F_OK); - this.fileList.push( - new ClientFile(this).updateFromBackend(backendFile), - ); - } catch (e) { - console.log(`${backendFile.path} 'does not exist'`); - this.backend.removeFile(backendFile); - } - }), + fetchedFiles.map(async (backendFile: IFile) => { + try { + await fs.access(backendFile.path, fs.constants.F_OK); + this.fileList.push( + new ClientFile(this).updateFromBackend(backendFile), + ); + } catch (e) { + console.log(`${backendFile.path} 'does not exist'`); + this.backend.removeFile(backendFile); + } + }), ); } private async removeFile(file: ClientFile): Promise { file.dispose(); this.fileList.remove(file); + // Deselect in case it was selected + this.rootStore.uiStore.deselectFile(file); return this.backend.removeFile(file); } @@ -105,24 +104,24 @@ class FileStore { // watching files would be better to remove invalid files // files could also have moved, removing them may be undesired then const existenceChecker = await Promise.all( - backendFiles.map( - async (backendFile) => { - try { - await fs.access(backendFile.path, fs.constants.F_OK); - return true; - } catch (err) { - this.backend.removeFile(backendFile); - const clientFile = this.fileList.find((f) => backendFile.id === f.id); - if (clientFile) { - await this.removeFile(clientFile); - } - return false; + backendFiles.map(async (backendFile) => { + try { + await fs.access(backendFile.path, fs.constants.F_OK); + return true; + } catch (err) { + this.backend.removeFile(backendFile); + const clientFile = this.fileList.find((f) => backendFile.id === f.id); + if (clientFile) { + await this.removeFile(clientFile); } - }, - ), + return false; + } + }), ); - const existingBackendFiles = backendFiles.filter((_, i) => existenceChecker[i]); + const existingBackendFiles = backendFiles.filter( + (_, i) => existenceChecker[i], + ); if (this.fileList.length === 0) { this.fileList.push(...this.filesFromBackend(existingBackendFiles)); diff --git a/src/renderer/frontend/stores/RootStore.ts b/src/renderer/frontend/stores/RootStore.ts index 371a8d74b..286250fbb 100644 --- a/src/renderer/frontend/stores/RootStore.ts +++ b/src/renderer/frontend/stores/RootStore.ts @@ -3,6 +3,13 @@ import FileStore from './FileStore'; import TagStore from './TagStore'; import UiStore from './UiStore'; +// import { configure } from 'mobx'; + +// This will throw exceptions whenver we try to modify the state directly without an action +// Actions will batch state modifications -> better for performance +// https://mobx.js.org/refguide/action.html +// configure({ enforceActions: 'observed' }); + /** * From: https://mobx.js.org/best/store.html * An often asked question is how to combine multiple stores without using singletons. diff --git a/src/renderer/frontend/stores/TagStore.ts b/src/renderer/frontend/stores/TagStore.ts index 5022a96a0..1a83f3eed 100644 --- a/src/renderer/frontend/stores/TagStore.ts +++ b/src/renderer/frontend/stores/TagStore.ts @@ -46,6 +46,7 @@ class TagStore { const tag = new ClientTag(this, tagName); this.tagList.push(tag); this.backend.createTag(tag.id, tag.name, tag.description); + return tag; } @action @@ -53,6 +54,9 @@ class TagStore { // Remove tag from state this.tagList.splice(this.tagList.indexOf(tag), 1); + // Remove tag from selection + this.rootStore.uiStore.tagSelection.remove(tag.id); + // Remove tag from files this.rootStore.fileStore.fileList .filter((f) => f.tags.includes(tag.id)) diff --git a/src/renderer/frontend/stores/UiStore.ts b/src/renderer/frontend/stores/UiStore.ts index c1de20f52..5d06b5a62 100644 --- a/src/renderer/frontend/stores/UiStore.ts +++ b/src/renderer/frontend/stores/UiStore.ts @@ -1,4 +1,4 @@ -import { action, observable } from 'mobx'; +import { action, observable, computed } from 'mobx'; import { ClientFile } from '../../entities/File'; import { ID } from '../../entities/ID'; @@ -29,13 +29,23 @@ class UiStore { @observable theme: 'LIGHT' | 'DARK' = 'DARK'; // UI - @observable isSidebarOpen: boolean = true; + @observable outlinerPage: 'IMPORT' | 'TAGS' | 'SEARCH' = 'TAGS'; + @observable isInspectorOpen: boolean = true; + @observable isSettingsOpen: boolean = false; // Selections - // Observable arrays recommened like this here https://github.com/mobxjs/mobx/issues/669#issuecomment-269119270 + // Observable arrays recommended like this here https://github.com/mobxjs/mobx/issues/669#issuecomment-269119270 readonly fileSelection = observable([]); readonly tagSelection = observable([]); + @computed get clientFileSelection(): ClientFile[] { + return this.fileSelection.map((id) => this.rootStore.fileStore.fileList.find((f) => f.id === id)) as ClientFile[]; + } + + @computed get clientTagSelection(): ClientTag[] { + return this.tagSelection.map((id) => this.rootStore.tagStore.tagList.find((t) => t.id === id)) as ClientTag[]; + } + constructor(rootStore: RootStore) { this.rootStore = rootStore; } @@ -50,10 +60,25 @@ class UiStore { @action selectTag(tag: ClientTag) { this.tagSelection.push(tag.id); + this.cleanFileSelection(); + this.rootStore.fileStore.fetchFilesByTagIDs(this.tagSelection); } @action deselectTag(tag: ClientTag) { this.tagSelection.remove(tag.id); + this.cleanFileSelection(); + this.rootStore.fileStore.fetchFilesByTagIDs(this.tagSelection); + } + + /** + * Deselect files that are not tagged with any tag in the current tag selection + */ + private cleanFileSelection() { + this.clientFileSelection.forEach((file) => { + if (!file.tags.some((t) => this.tagSelection.includes(t))) { + this.deselectFile(file); + } + }); } } diff --git a/src/renderer/index.html b/src/renderer/index.html index d4f8149e9..f1ff2c3fa 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,6 +5,6 @@ Allusion - Your Visual Library -
+
diff --git a/src/renderer/style.scss b/src/renderer/style.scss index bafbadfd6..55187b893 100644 --- a/src/renderer/style.scss +++ b/src/renderer/style.scss @@ -2,6 +2,7 @@ @import "~normalize.css"; @import "~@blueprintjs/core/lib/css/blueprint.css"; @import "~@blueprintjs/icons/lib/css/blueprint-icons.css"; +@import "~@blueprintjs/select/lib/css/blueprint-select.css"; /** * Sass variables, for color parameters etc. @@ -20,26 +21,39 @@ html { body { background: #444444; - display: flex; - justify-content: space-between; - flex-direction: row; margin: 0px; width: 100%; height: 100%; + overflow: hidden; +} + +#app { + --outliner-width: 16em; + --inspector-width: 24em; + --toolbar-height: 2.4rem; + --content-area-height: calc(100vh - var(--toolbar-height)); } -.app { - --sidebar-width: 16em; +main { + grid-area: main; + overflow: auto; } -.main { - margin-left: var(--sidebar-width); +#layoutContainer { + display: grid; + grid-template-rows: var(--toolbar-height) var(--content-area-height); + grid-template-columns: min-content 1fr min-content; + grid-template-areas: + "toolbar toolbar toolbar" + "outliner main inspector"; } -.column { +#toolbar { + grid-area: toolbar; display: flex; - flex-direction: column; - height: 100%; + justify-content: space-between; + align-items: center; + border-bottom: 3px solid $black; } .thumbnail { @@ -72,6 +86,13 @@ body { transform: scale(0.9); } +.thumbnail.open img { + outline-color: red; + outline-width: 5px; + outline-offset: -5px; + outline-style: solid; +} + .thumbnail.droppable { background: $pt-intent-success; } @@ -80,7 +101,7 @@ body { background: $pt-intent-warning; } -.thumbnail.droppable img{ +.thumbnail.droppable img { transform: scale(0.95); } @@ -98,75 +119,99 @@ body { margin: 0.5em; } -.thumbnailSelector { - opacity: 0.5; - border-radius: 100%; - cursor: pointer; - background-color: rgba(0, 0, 0, 0.5); - padding: 0.5em; - position: absolute; - top: 0; -} - -.thumbnailSelector.selected { - opacity: 1; -} - -.thumbnailSelector:hover { - opacity: 1; -} - -.sidebar { - width: var(--sidebar-width); +nav { + grid-area: outliner; + background: #333333; + overflow: auto; flex-shrink: 0; - overflow-x: hidden; - height: 100%; + width: 0; + min-width: 0; + transition: min-width 0.1s ease-out; +} - position: fixed; - top: 0; - - background: #333333; +nav.outlinerOpen { + min-width: var(--outliner-width); + width: fit-content; } -.sidebar h4 { +nav h4 { padding: 4px; margin: 0; } .gallery { - display: flex; - flex-direction: column; width: 100%; height: 100%; padding: 8px; } -.fileSelectionHeader { - display: flex; - justify-content: space-between; - position: fixed; - top: 0; - width: calc(100% - var(--sidebar-width) - 2em); +.popoverContent { + padding: 0.5em; +} + +#query-overview .bp3-tag { + margin: 0 0 0.5em 0.5em; +} + +#inspector { + grid-area: inspector; + overflow: auto; + flex-shrink: 0; + width: 0; + min-width: 0; + transition: min-width 0.1s ease-out; +} + +#inspector.inspectorOpen { + min-width: var(--inspector-width); + width: fit-content; +} + +#inspector > section { + border-bottom: 3px solid #181818; padding: 1em; - margin: 0.5em; - z-index: 10; - background: #555555; - box-shadow: 0px 2px 5px 0px rgba(0,0,0,0.75); } -.fileSelectionHeader .icon { - cursor: pointer; - padding: 0.5em; - margin: -0.5em; +.inpectorHeading { + font-weight: bold; } -.fileSelectionHeader .icon:hover { - background: #888888; - border-radius: 100%; +// The fill option of the MultiTagSelect does not work atm. Workaround is to manually set width +#inspector .bp3-popover-target { + width: 100%; } -.popoverContent { - padding: 0.5em; +#filePreview > img { + padding: 0.3em; + background-color: #333333; + width: 100%; + max-height: calc(var(--inspector-width) - 2em); + object-fit: contain; +} + +#fileOverview { + text-align: center; +} + +#fileInfo { + display: grid; + grid-template-columns: min-content 1fr; + grid-column-gap: 1em; +} + +#fileInfo > div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fileInfoValue { + text-align: right; +} + +#fileTag .bp3-tag { + margin-bottom: 5px; + margin-right: 5px; } .contextMenuTarget { @@ -175,3 +220,31 @@ body { outline-offset: -2px; outline-style: solid; } + +// --outliner-width + --thumbnail-size +@media (max-width: 40em) { + #inspector.inspectorOpen { + min-width: 0; + } +} + +// --outliner-width +@media (max-width: 16em) { + #inspector { + display: none; + } + + nav { + display: none; + } +} + +.error-boundary { + grid-area: main; +} + +.error-boundary .message { + text-align: left; + width: 800px; + max-width: 800px; +} diff --git a/tslint.json b/tslint.json index 33d7d0a74..e585e1c5a 100644 --- a/tslint.json +++ b/tslint.json @@ -13,8 +13,7 @@ "no-empty-interface": false, "indent": [true, "spaces", 2], "member-access": [false, "warning"], - "ordered-imports": [false, "warning"], - "newline-per-chained-call": true + "ordered-imports": [false, "warning"] }, "jsRules": { "max-line-length": { diff --git a/yarn.lock b/yarn.lock index 514bd4d04..919c246a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -143,12 +143,13 @@ lodash "^4.17.11" to-fast-properties "^2.0.0" -"@blueprintjs/core@^3.12.0": - version "3.12.0" - resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-3.12.0.tgz#98709ba9d0d0813c8e974eb49f1b5abd6c8e271b" +"@blueprintjs/core@^3.15.0": + version "3.15.1" + resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-3.15.1.tgz#9792e9fb7e2e066dd5339fadeaf2f85b1485832a" + integrity sha512-M8ltbqqlMZuZ6SEuqo/3Fr59ZcUfd8Er7ocbm7EACVfRW7dRhOCd/TKkf2kfICNtCDwznwXk0iAePLXZhUGtQg== dependencies: - "@blueprintjs/icons" "^3.5.1" - "@types/dom4" "^2.0.0" + "@blueprintjs/icons" "^3.8.0" + "@types/dom4" "^2.0.1" classnames "^2.2" dom4 "^2.0.1" normalize.css "^8.0.0" @@ -158,18 +159,19 @@ resize-observer-polyfill "^1.5.0" tslib "^1.9.0" -"@blueprintjs/icons@^3.5.1": - version "3.5.1" - resolved "https://registry.yarnpkg.com/@blueprintjs/icons/-/icons-3.5.1.tgz#29dabfa2edb3efeefdc472b19e71736fadf5666b" +"@blueprintjs/icons@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@blueprintjs/icons/-/icons-3.8.0.tgz#7c77c67e4a241740f803f05e4f6e3ce43c6d6560" + integrity sha512-yHaRQ3vfV9Gf3foZ4ONtxddz+u5ufkHqHj8Ia5VhPbFgG4el+cPdmsGGIIM72rgKS1KQa5Ay+ggjpByUlXvrKg== dependencies: classnames "^2.2" tslib "^1.9.0" -"@blueprintjs/select@^3.6.0": - version "3.6.0" - resolved "https://registry.yarnpkg.com/@blueprintjs/select/-/select-3.6.0.tgz#6c42107c99c4d0007eedde239218956210d53427" +"@blueprintjs/select@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@blueprintjs/select/-/select-3.8.0.tgz#903cc9ab50da4cec08f559be27b7fa7f9f1560e5" dependencies: - "@blueprintjs/core" "^3.12.0" + "@blueprintjs/core" "^3.15.0" classnames "^2.2" tslib "^1.9.0" @@ -386,7 +388,7 @@ dependencies: "@babel/types" "^7.3.0" -"@types/dom4@^2.0.0": +"@types/dom4@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/dom4/-/dom4-2.0.1.tgz#506d5781b9bcab81bd9a878b198aec7dee2a6033" @@ -5982,7 +5984,7 @@ webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" -webpack-cli@^3.3.0: +webpack-cli@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.0.tgz#55c8a74cae1e88117f9dda3a801c7272e93ca318" integrity sha512-t1M7G4z5FhHKJ92WRKwZ1rtvi7rHc0NZoZRbSkol0YKl4HvcC8+DsmGDmK7MmZxHSAetHagiOsjOB6MmzC2TUw== @@ -6006,7 +6008,7 @@ webpack-sources@^1.1.0, webpack-sources@^1.3.0: source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.29.6: +webpack@^4.28.3: version "4.29.6" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.29.6.tgz#66bf0ec8beee4d469f8b598d3988ff9d8d90e955" integrity sha512-MwBwpiE1BQpMDkbnUUaW6K8RFZjljJHArC6tWQJoFm0oQtfoSebtg4Y7/QHnJ/SddtjYLHaKGX64CFjG5rehJw==