diff --git a/src/renderer/frontend/App.tsx b/src/renderer/frontend/App.tsx index da313e35f..e3627d5f5 100644 --- a/src/renderer/frontend/App.tsx +++ b/src/renderer/frontend/App.tsx @@ -1,9 +1,3 @@ -import { - Breadcrumbs, - IBreadcrumbProps, - InputGroup, - Button, -} from '@blueprintjs/core'; import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -11,45 +5,27 @@ import FileList from './components/FileList'; 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 {} +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 index afa44d8ed..c110db47f 100644 --- a/src/renderer/frontend/components/FileInfo.tsx +++ b/src/renderer/frontend/components/FileInfo.tsx @@ -48,7 +48,7 @@ const SingleFileInfo = observer(({ file }: { file: ClientFile }) => { ); return ( -
+
{fileInfoList.map(({ key, value }) => [
{key} diff --git a/src/renderer/frontend/components/FileList.tsx b/src/renderer/frontend/components/FileList.tsx index f5975b6d0..faf4f5556 100644 --- a/src/renderer/frontend/components/FileList.tsx +++ b/src/renderer/frontend/components/FileList.tsx @@ -1,72 +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 selectionModeOn = uiStore.fileSelection.length > 0; + const handleDeselectTag = useCallback( + (_, props: ITagProps) => { + const clickedTag = tagStore.tagList.find((t) => t.id === props.id); + if (clickedTag) { + uiStore.deselectTag(clickedTag); + } + }, + [], + ); return (
- { selectionModeOn && ( - 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 index d70085fba..120dcd8ab 100644 --- a/src/renderer/frontend/components/FileTag.tsx +++ b/src/renderer/frontend/components/FileTag.tsx @@ -109,7 +109,7 @@ const Multi = observer(({ files }: IFileTagProps) => { const FileTag = ({ files }: IFileTagProps) => { return ( -
+
Tags
{files.length === 1 ? ( 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 index 1f9713fdf..9eefd176d 100644 --- a/src/renderer/frontend/components/Inspector.tsx +++ b/src/renderer/frontend/components/Inspector.tsx @@ -48,11 +48,11 @@ const Inspector = ({ rootStore: { uiStore } }: IInspectorProps) => { if (selectedFiles.length > 0) { return (
+ + +); + +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 852511047..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,18 +77,17 @@ 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); + } + }), ); } @@ -107,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/UiStore.ts b/src/renderer/frontend/stores/UiStore.ts index 821130520..5d06b5a62 100644 --- a/src/renderer/frontend/stores/UiStore.ts +++ b/src/renderer/frontend/stores/UiStore.ts @@ -29,8 +29,9 @@ class UiStore { @observable theme: 'LIGHT' | 'DARK' = 'DARK'; // UI - @observable isOutlinerOpen: boolean = true; + @observable outlinerPage: 'IMPORT' | 'TAGS' | 'SEARCH' = 'TAGS'; @observable isInspectorOpen: boolean = true; + @observable isSettingsOpen: boolean = false; // Selections // Observable arrays recommended like this here https://github.com/mobxjs/mobx/issues/669#issuecomment-269119270 @@ -59,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/style.scss b/src/renderer/style.scss index 286315303..55187b893 100644 --- a/src/renderer/style.scss +++ b/src/renderer/style.scss @@ -30,18 +30,30 @@ body { #app { --outliner-width: 16em; --inspector-width: 24em; + --toolbar-height: 2.4rem; + --content-area-height: calc(100vh - var(--toolbar-height)); } main { grid-area: main; overflow: auto; - height: 100vh; } #layoutContainer { display: grid; + grid-template-rows: var(--toolbar-height) var(--content-area-height); grid-template-columns: min-content 1fr min-content; - grid-template-areas: "outliner main inspector"; + grid-template-areas: + "toolbar toolbar toolbar" + "outliner main inspector"; +} + +#toolbar { + grid-area: toolbar; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 3px solid $black; } .thumbnail { @@ -89,7 +101,7 @@ main { background: $pt-intent-warning; } -.thumbnail.droppable img{ +.thumbnail.droppable img { transform: scale(0.95); } @@ -112,8 +124,14 @@ nav { background: #333333; overflow: auto; flex-shrink: 0; - width: var(--outliner-width); - height: 100vh; + width: 0; + min-width: 0; + transition: min-width 0.1s ease-out; +} + +nav.outlinerOpen { + min-width: var(--outliner-width); + width: fit-content; } nav h4 { @@ -127,32 +145,12 @@ nav h4 { padding: 8px; } -.fileSelectionHeader { - display: flex; - justify-content: space-between; - position: fixed; - top: 0; - width: calc(100% - var(--outliner-width) - var(--inspector-width) - 2em); - 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; +.popoverContent { padding: 0.5em; - margin: -0.5em; } -.fileSelectionHeader .icon:hover { - background: #888888; - border-radius: 100%; -} - -.popoverContent { - padding: 0.5em; +#query-overview .bp3-tag { + margin: 0 0 0.5em 0.5em; } #inspector { @@ -160,8 +158,8 @@ nav h4 { overflow: auto; flex-shrink: 0; width: 0; - height: 100vh; - transition: width 0.1s ease-out; + min-width: 0; + transition: min-width 0.1s ease-out; } #inspector.inspectorOpen { @@ -169,7 +167,7 @@ nav h4 { width: fit-content; } -#inspector section { +#inspector > section { border-bottom: 3px solid #181818; padding: 1em; } @@ -178,12 +176,12 @@ nav h4 { font-weight: bold; } -// The fill option of the MultiTagSelect does not work atm. Workaround is to manually set width +// The fill option of the MultiTagSelect does not work atm. Workaround is to manually set width #inspector .bp3-popover-target { width: 100%; } -.filePreview img { +#filePreview > img { padding: 0.3em; background-color: #333333; width: 100%; @@ -191,17 +189,17 @@ nav h4 { object-fit: contain; } -.fileOverview { +#fileOverview { text-align: center; } -.fileInfo { +#fileInfo { display: grid; grid-template-columns: min-content 1fr; grid-column-gap: 1em; } -.fileInfo div { +#fileInfo > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -211,7 +209,7 @@ nav h4 { text-align: right; } -.fileTag .bp3-tag { +#fileTag .bp3-tag { margin-bottom: 5px; margin-right: 5px; } @@ -225,14 +223,28 @@ nav h4 { // --outliner-width + --thumbnail-size @media (max-width: 40em) { - #inspector { - display: none; + #inspector.inspectorOpen { + min-width: 0; } } // --outliner-width @media (max-width: 16em) { + #inspector { + display: none; + } + nav { display: none; } -} \ No newline at end of file +} + +.error-boundary { + grid-area: main; +} + +.error-boundary .message { + text-align: left; + width: 800px; + max-width: 800px; +}