diff --git a/README.md b/README.md
index f130172..7a6e337 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Image Classification Dataset Prepper
-https://user-images.githubusercontent.com/1139927/209484470-7ca2c62c-bf67-4cfa-be6f-5a9e014e33dd.mp4
+https://user-images.githubusercontent.com/1139927/209875258-38f3774f-0d5e-4926-964c-a2f3a80ec60a.mp4
@@ -49,8 +49,9 @@ This will spin up a dev server which auto-reloads on file changes.
- ← - prev image
- Space - delete image
- CMD / CTRL + Z - undo delete
+- a - pick image (this moves the image into a subfolder called `_picked`)
- CMD / CTRL + R - refresh the app
-- click the "↻" icon on the top right of the screen to reset history (visited folders appear at a lower opacity for tracking purposes)
+- click the "↻" icon on the top right of the screen to reset history (visited folders whose contents were looped through at least once appear at a lower opacity for tracking purposes)
- ♫ pop noise sounds each time a full loop completes when cycling through a directory
## Folder structure
@@ -80,7 +81,6 @@ Just make sure that the selected folder contains either **only images** or **onl
- Automate deleting broken images
- Crop and keep only part of an image
- Convert all images to a particular format
-- Rename all images with numeric file names
## Credits
diff --git a/assets/video.mp4 b/assets/video.mp4
index 168659a..cc0c46a 100644
Binary files a/assets/video.mp4 and b/assets/video.mp4 differ
diff --git a/cspell.json b/cspell.json
new file mode 100644
index 0000000..2ad30f9
--- /dev/null
+++ b/cspell.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
+ "version": "0.2",
+ "dictionaryDefinitions": [
+ {
+ "name": "dictionary",
+ "path": "./dictionary.txt",
+ "addWords": true
+ }
+ ],
+ "dictionaries": ["dictionary"],
+ "ignorePaths": ["node_modules", "dictionary.txt"]
+}
diff --git a/dictionary.txt b/dictionary.txt
new file mode 100644
index 0000000..d46732f
--- /dev/null
+++ b/dictionary.txt
@@ -0,0 +1,13 @@
+arrowleft
+arrowright
+asar
+chakra
+compat
+electronmon
+Kashem
+nsis
+pmmmwh
+svgr
+teamsupercell
+Towhid
+unstage
diff --git a/package.json b/package.json
index 2d36112..5aaf38a 100644
--- a/package.json
+++ b/package.json
@@ -2,8 +2,8 @@
"name": "image-reviewer",
"main": "./src/main/main.ts",
"author": "Towhid Kashem",
- "description": "A minimal desktop app to quickly review and delete images when collecting data sets for image classification models",
- "version": "0.1.0",
+ "description": "A desktop file explorer with keyboard shortcuts to quickly prep images when collecting datasets for training image classification models",
+ "version": "0.2.0",
"license": "MIT",
"browserslist": {
"production": [
@@ -101,8 +101,10 @@
"pretty": "prettier --write '**/*.{js,ts,tsx,mts,json,yaml,yml,md}'",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"type-check": "tsc",
+ "bump": "yarn version --new-version",
"git:commit": "git status && git add . && git commit -m 'work' && git push",
"git:commit-skip-hooks": "git status && git add . && git commit -m 'work' --no-verify && git push --no-verify",
+ "git:rebase": "git fetch origin && git rebase -i origin/main",
"git:reset": "git clean --force && git reset --hard",
"git:undo-last-commit": "git reset --soft HEAD~1",
"git:unstage-all-files": "git reset HEAD -- .",
diff --git a/release/app/package-lock.json b/release/app/package-lock.json
index 44cb5ba..e6dd702 100644
--- a/release/app/package-lock.json
+++ b/release/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "image-reviewer",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "image-reviewer",
- "version": "0.1.0",
+ "version": "0.2.0",
"hasInstallScript": true,
"license": "MIT"
}
diff --git a/release/app/package.json b/release/app/package.json
index 630fb1d..1936990 100644
--- a/release/app/package.json
+++ b/release/app/package.json
@@ -1,7 +1,7 @@
{
"name": "image-reviewer",
- "version": "0.1.0",
- "description": "A minimal desktop app to quickly review and delete images when collecting data sets for image classification models",
+ "version": "0.2.0",
+ "description": "A desktop file explorer with keyboard shortcuts to quickly prep images when collecting datasets for training image classification models",
"license": "MIT",
"author": "Towhid Kashem",
"main": "./dist/main/main.js",
diff --git a/src/main/handlers.ts b/src/main/handlers.ts
index 6995d6e..249ad43 100644
--- a/src/main/handlers.ts
+++ b/src/main/handlers.ts
@@ -3,11 +3,14 @@ import path from 'path';
import { ipcMain, IpcMainInvokeEvent } from 'electron';
import { channels } from '../renderer/_data';
+const TRASH_DIR = '_trash';
+const PICKED_DIR = '_picked';
+
const handleListDirectory = async (
_e: IpcMainInvokeEvent,
filePath: string
): Promise> => {
- const BLOCK_LIST = ['1', '.DS_Store', 'trash.tmp'];
+ const BLOCK_LIST = ['1', '.DS_Store', TRASH_DIR, PICKED_DIR];
try {
const contents = fs
@@ -49,7 +52,7 @@ const handleDeleteFile = async (
const parentDir = pathSegments.join('/');
- const trashDir = `${parentDir}/trash.tmp`;
+ const trashDir = `${parentDir}/${TRASH_DIR}`;
if (!fs.existsSync(trashDir)) fs.mkdirSync(trashDir);
@@ -74,7 +77,7 @@ const handleUndoDeleteFile = async (
const parentDir = pathSegments.join('/');
- const trashDir = `${parentDir}/trash.tmp`;
+ const trashDir = `${parentDir}/${TRASH_DIR}`;
fs.renameSync(`${trashDir}/${fileToDelete}`, filePath);
@@ -91,10 +94,39 @@ const handleEmptyTrash = async (
filePath: string
): Promise> => {
try {
- fs.rmSync(`${filePath}/trash.tmp`, {
- recursive: true,
- force: true
- });
+ const TRASH_DIR_PATH = `/${filePath}/${TRASH_DIR}`;
+
+ if (fs.existsSync(TRASH_DIR_PATH)) {
+ fs.rmSync(TRASH_DIR_PATH, {
+ recursive: true,
+ force: true
+ });
+ }
+
+ return { error: null };
+ } catch (error) {
+ return {
+ error: new Error(error as string)
+ };
+ }
+};
+
+const handleMoveFile = async (
+ _e: IpcMainInvokeEvent,
+ filePath: string
+): Promise> => {
+ try {
+ const pathSegments = filePath.split('/');
+
+ const fileToMove = pathSegments.pop();
+
+ const parentDir = pathSegments.join('/');
+
+ const pickedDir = `${parentDir}/${PICKED_DIR}`;
+
+ if (!fs.existsSync(pickedDir)) fs.mkdirSync(pickedDir);
+
+ fs.renameSync(filePath, `${pickedDir}/${fileToMove}`);
return { error: null };
} catch (error) {
@@ -106,6 +138,9 @@ const handleEmptyTrash = async (
// endpoints
ipcMain.handle(channels.LIST_DIR, handleListDirectory);
+
ipcMain.handle(channels.DELETE_FILE, handleDeleteFile);
ipcMain.handle(channels.UNDO_DELETE, handleUndoDeleteFile);
ipcMain.handle(channels.EMPTY_TRASH, handleEmptyTrash);
+
+ipcMain.handle(channels.MOVE_FILE, handleMoveFile);
diff --git a/src/main/main.ts b/src/main/main.ts
index b19ed4b..894f565 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -1,5 +1,5 @@
import path from 'path';
-import { app, shell, protocol, BrowserWindow } from 'electron';
+import { app, protocol, BrowserWindow, ipcMain, dialog } from 'electron';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './_utils';
@@ -12,7 +12,7 @@ if (process.env.NODE_ENV === 'production') {
sourceMapSupport.install();
}
-const isDebug =
+export const isDebug =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
if (isDebug) require('electron-debug')({ showDevTools: false });
@@ -22,15 +22,17 @@ const installExtensions = async () => {
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS'];
- return installer
- .default(
+ try {
+ return installer.default(
extensions.map((name) => installer[name]),
forceDownload
- )
- .catch(console.log);
+ );
+ } catch (error) {
+ console.error(error);
+ }
};
-const createWindow = async () => {
+const createWindow = async (): Promise => {
if (isDebug) await installExtensions();
const RESOURCES_PATH = app.isPackaged
@@ -54,56 +56,45 @@ const createWindow = async () => {
mainWindow.loadURL(resolveHtmlPath('index.html'));
- mainWindow.on('ready-to-show', () => {
- if (!mainWindow) throw new Error('`mainWindow` is not defined');
+ mainWindow
+ .on('ready-to-show', () => {
+ if (!mainWindow) throw new Error('`mainWindow` is not defined');
+ if (process.env.START_MINIMIZED) return mainWindow.minimize();
- if (process.env.START_MINIMIZED) {
- mainWindow.minimize();
- } else {
mainWindow.show();
- }
- });
-
- mainWindow.on('closed', () => {
- mainWindow = null;
- });
-
- const menuBuilder = new MenuBuilder(mainWindow);
- menuBuilder.buildMenu();
+ })
+ .on('closed', () => {
+ mainWindow = null;
+ });
- // open urls in the user's browser
- mainWindow.webContents.setWindowOpenHandler((edata) => {
- shell.openExternal(edata.url);
- return { action: 'deny' };
- });
+ new MenuBuilder(mainWindow).buildMenu();
};
-// event listeners
-
-// respect the osx convention of having the application in memory even after all windows have been closed
-app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') app.quit();
-});
-
-// restrict navigation to known domains for better security
-const NAV_ALLOW_LIST = ['https://image-reviewer.com'];
-app.on('web-contents-created', (_, contents) => {
- contents.on('will-navigate', (e, navigationUrl) => {
- const parsedUrl = new URL(navigationUrl);
+app
+ // respect the osx convention of having the application in memory
+ // even after all windows have been closed
+ .on('window-all-closed', () => {
+ if (process.platform !== 'darwin') app.quit();
+ })
+ // restrict navigation to known domains for better security
+ .on('web-contents-created', (_, contents) => {
+ contents.on('will-navigate', (e, navigationUrl) => {
+ const parsedUrl = new URL(navigationUrl);
+ const NAV_ALLOW_LIST = ['https://image-reviewer.com'];
- if (!NAV_ALLOW_LIST.includes(parsedUrl.origin)) {
- e.preventDefault();
- }
+ if (!NAV_ALLOW_LIST.includes(parsedUrl.origin)) e.preventDefault();
+ });
});
-});
-app
- .whenReady()
- .then(() => {
+const onAppReady = async (): Promise => {
+ try {
+ await app.whenReady();
+
createWindow();
+ // on mac its common to re-create a window in the app when the
+ // dock icon is clicked and there are no other windows open
app.on('activate', () => {
- // on mac it's common to re-create a window in the app when the dock icon is clicked and there are no other windows open
if (mainWindow === null) createWindow();
});
@@ -118,5 +109,22 @@ app
return callback({ error: 404 });
}
});
- })
- .catch(console.log);
+
+ // open directory picker dialog
+ ipcMain.on('open-picker-dialog', async () => {
+ try {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openDirectory']
+ });
+
+ mainWindow.webContents.send('dialog-picker-result', result);
+ } catch (error) {
+ console.error(error);
+ }
+ });
+ } catch (error) {
+ console.error(error);
+ }
+};
+
+onAppReady();
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 3357a99..84e0e60 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -1,7 +1,5 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron';
-
-const isDev =
- process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
+import { isDebug } from './main';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
@@ -16,7 +14,7 @@ export default class MenuBuilder {
}
buildMenu(): Menu {
- if (isDev) this.setupDevelopmentEnvironment();
+ if (isDebug) this.setupDevelopmentEnvironment();
const template =
process.platform === 'darwin'
@@ -90,7 +88,7 @@ export default class MenuBuilder {
click: () =>
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen())
},
- isDev
+ isDebug
? {
label: 'Toggle Developer Tools',
accelerator: 'Alt+Command+I',
@@ -155,7 +153,7 @@ export default class MenuBuilder {
click: () =>
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen())
},
- isDev
+ isDebug
? {
label: 'Toggle &Developer Tools',
accelerator: 'Alt+Ctrl+I',
diff --git a/src/main/preload.ts b/src/main/preload.ts
index d613f9d..c3826a5 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -1,16 +1,23 @@
import { contextBridge, ipcRenderer } from 'electron';
import { ChannelT } from '../renderer/global';
-import { channels } from '../renderer/_data';
-contextBridge.exposeInMainWorld('electron', {
+contextBridge.exposeInMainWorld('app', {
ipcRenderer: {
invoke(channel: ChannelT, args: unknown): Promise {
return ipcRenderer.invoke(channel, args);
- },
- removeAllListeners: (channel: ChannelT): void => {
- if (Object.values(channels).includes(channel)) {
- ipcRenderer.removeAllListeners(channel);
- }
}
+ },
+ openDialogPicker() {
+ ipcRenderer.send('open-picker-dialog');
+ },
+ onDialogPickerResult(
+ callback: (result: Electron.OpenDialogReturnValue) => void
+ ) {
+ ipcRenderer.on(
+ 'dialog-picker-result',
+ (_e, result: Electron.OpenDialogReturnValue) => {
+ callback(result);
+ }
+ );
}
});
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index c8581d6..8e2fdc4 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -9,17 +9,23 @@ import { AppContext } from './_data';
export function App() {
const images = useRef([]);
+
const [pathSegments, setPathSegments] = useState([]);
const [directories, setDirectories] = useStateCallback([]);
+ const [visitedDirs, setVisitedDirs] = useState(
+ JSON.parse(localStorage.getItem('visitedDirs')) ?? []
+ );
return (
diff --git a/src/renderer/ChooseDirectory.tsx b/src/renderer/ChooseDirectory.tsx
index a799542..307f8f1 100644
--- a/src/renderer/ChooseDirectory.tsx
+++ b/src/renderer/ChooseDirectory.tsx
@@ -1,86 +1,40 @@
-import { useContext, useRef } from 'react';
+import { useContext, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import {
- useToast,
- useDisclosure,
- Flex,
- Button,
- AlertDialog,
- AlertDialogOverlay,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogFooter,
- AlertDialogBody
-} from '@chakra-ui/react';
-import { UploadButton } from './UploadButton';
+import { useToast, Flex, Button } from '@chakra-ui/react';
import { AppContext, channels, toastConfig, ERROR_DURATION } from './_data';
-import { getRootDir } from './_utils';
+import { removeStartEndSlash } from './_utils';
import { Logo } from './Logo';
-const { ipcRenderer } = window.electron;
-
-const DIR_INITIAL_STATE: {
- fileInput: string;
- dirPicker: string;
-} = {
- fileInput: null,
- dirPicker: null
-};
+const { ipcRenderer, openDialogPicker, onDialogPickerResult } = window.app;
export function ChooseDirectory() {
const navigate = useNavigate();
const toast = useToast(toastConfig);
- const dirSelection = useRef(DIR_INITIAL_STATE);
-
- const { isOpen, onOpen, onClose } = useDisclosure();
-
const { setPathSegments, setDirectories, images } = useContext(AppContext);
- const chooseFolder = (e: React.SyntheticEvent): void => {
- dirSelection.current.fileInput = e.currentTarget.files[0].path;
- onOpen();
- };
-
- const chooseAgain = async (): Promise => {
- try {
- const { name } = await window.showDirectoryPicker();
- dirSelection.current.dirPicker = name;
-
- getFolderContents();
- onClose();
- } catch (error) {
- toast({
- description: error.toString(),
- status: 'error',
- duration: ERROR_DURATION
- });
- }
- };
-
- const resetHistory = (): void => {
- setPathSegments([]);
- setDirectories([]);
- images.current = [];
- dirSelection.current = DIR_INITIAL_STATE;
- };
-
- const getFolderContents = async (): Promise => {
- const { fileInput, dirPicker } = dirSelection.current;
+ useEffect(() => {
+ onDialogPickerResult(({ canceled, filePaths }) => {
+ if (!canceled) {
+ getFolderContents(filePaths[0]);
+ }
+ });
+ }, []);
- const { segments, path } = getRootDir(fileInput, dirPicker);
+ const getFolderContents = async (dirPath: string): Promise => {
+ const pathSegments = removeStartEndSlash(dirPath).split('/');
resetHistory();
try {
const { data, error } = await ipcRenderer.invoke<
ResponseT
- >(channels.LIST_DIR, path);
+ >(channels.LIST_DIR, dirPath);
if (error) throw error;
- setPathSegments(segments);
+ setPathSegments(pathSegments);
const isParent = data.every(({ isDir }) => isDir);
@@ -102,6 +56,12 @@ export function ChooseDirectory() {
}
};
+ const resetHistory = (): void => {
+ setPathSegments([]);
+ setDirectories([]);
+ images.current = [];
+ };
+
return (
- Choose Folder
-
-
-
-
-
- Choose the same folder again
-
-
-
- This is kinda annoying but due to some file system limitations
- you'll need to pick the same folder twice!
-
-
-
-
-
-
-
-
+
);
}
diff --git a/src/renderer/DirectoryContent.tsx b/src/renderer/DirectoryContent.tsx
index 6429715..698960d 100644
--- a/src/renderer/DirectoryContent.tsx
+++ b/src/renderer/DirectoryContent.tsx
@@ -14,18 +14,26 @@ import {
import { FcOpenedFolder, FcDocument } from 'react-icons/fc';
import { IoLocation, IoArrowRedo, IoImage } from 'react-icons/io5';
import { Navigation } from './Navigation';
-import { AppContext, channels, toastConfig, ERROR_DURATION } from './_data';
-import { sortImages, isImage } from './_utils';
+import {
+ AppContext,
+ channels,
+ toastConfig,
+ ERROR_DURATION,
+ NAV_KEYS
+} from './_data';
+import { sortImages, isValidImage } from './_utils';
import popSound from '../../assets/pop.mp3';
-const { ipcRenderer } = window.electron;
+const { ipcRenderer } = window.app;
export function DirectoryContent() {
const toast = useToast(toastConfig);
- const { pathSegments, directories, images } = useContext(AppContext);
+ const { pathSegments, directories, visitedDirs, setVisitedDirs, images } =
+ useContext(AppContext);
const imageIndex = useRef(0);
+
const deleteHistory = useRef([]);
const isDeleteTouched = useRef(false);
@@ -43,23 +51,38 @@ export function DirectoryContent() {
window.addEventListener('keydown', handleKeyboardNav);
return () => {
- if (isDeleteTouched.current) emptyTrash();
+ emptyTrash();
window.removeEventListener('keydown', handleKeyboardNav);
};
}, []);
+ const markDirVisited = (): void => {
+ const curPath = `/${pathSegments.join('/')}`;
+
+ if (visitedDirs.includes(curPath)) return;
+
+ const newVisitedDirs = [...visitedDirs, curPath];
+
+ setVisitedDirs(newVisitedDirs);
+ localStorage.setItem('visitedDirs', JSON.stringify(newVisitedDirs));
+ };
+
const handleKeyboardNav = (e: KeyboardEvent): void | Promise => {
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z')
- return undoDelete();
+ const KEY = e.key.toLowerCase();
+ const isUndo = (e.ctrlKey || e.metaKey) && KEY === 'z';
+
+ if (isUndo) return undoDelete();
- switch (e.key) {
- case ' ':
+ switch (KEY) {
+ case NAV_KEYS.delete:
return deleteImage();
- case 'ArrowRight':
+ case NAV_KEYS.next:
return nextImage();
- case 'ArrowLeft':
+ case NAV_KEYS.prev:
return prevImage();
+ case NAV_KEYS.pick:
+ return pickImage();
}
};
@@ -77,6 +100,10 @@ export function DirectoryContent() {
setLoopCount((prevCount) => prevCount + 1);
new Audio(popSound).play();
+
+ // only mark the directory as visited if the
+ // user has looped through all the images at least once
+ markDirVisited();
}
}
@@ -150,8 +177,7 @@ export function DirectoryContent() {
if (error) throw error;
- // put it back in the images array and sort again
- // and don't forget to re-render after...
+ // put it back in the images array
images.current.push(lastDeleted);
sortImages(images.current);
setTriggerRender((prev) => !prev);
@@ -187,6 +213,43 @@ export function DirectoryContent() {
}
};
+ const pickImage = async (): Promise => {
+ const pickedImage = images.current[imageIndex.current];
+
+ try {
+ const { error } = await ipcRenderer.invoke>(
+ channels.MOVE_FILE,
+ pickedImage.path
+ );
+
+ if (error) throw error;
+
+ images.current = sortImages(
+ images.current.filter(({ path }) => path !== pickedImage.path)
+ );
+
+ setTriggerRender((prev) => !prev);
+
+ if (images.current.length === 0) {
+ setIsDirEmpty(true);
+
+ window.removeEventListener('keyup', handleKeyboardNav);
+ }
+
+ toast({
+ description: 'Image picked!',
+ status: 'info',
+ variant: 'subtle'
+ });
+ } catch (error) {
+ toast({
+ description: error.toString(),
+ status: 'error',
+ duration: ERROR_DURATION
+ });
+ }
+ };
+
const { path, name, extension } = images.current[imageIndex.current];
const totalImages = images.current.length;
@@ -212,7 +275,7 @@ export function DirectoryContent() {
}
];
- const isImageFile = isImage({ extension: extension.slice(1) });
+ const isImageFile = isValidImage({ extension: extension.slice(1) });
const NAV_BAR_HEIGHT = '105px'; // nav height (55px) + vertical margins (25px * 2)
diff --git a/src/renderer/DirectoryList.tsx b/src/renderer/DirectoryList.tsx
index c2b7ee9..760080b 100644
--- a/src/renderer/DirectoryList.tsx
+++ b/src/renderer/DirectoryList.tsx
@@ -1,4 +1,4 @@
-import { useState, useContext } from 'react';
+import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast, SimpleGrid, Flex, Heading, Icon } from '@chakra-ui/react';
import { FcFolder, FcFile } from 'react-icons/fc';
@@ -6,18 +6,15 @@ import { Navigation } from './Navigation';
import { AppContext, channels, toastConfig, ERROR_DURATION } from './_data';
import { sortImages } from './_utils';
-const { ipcRenderer } = window.electron;
+const { ipcRenderer } = window.app;
export function DirectoryList() {
const navigate = useNavigate();
const toast = useToast(toastConfig);
- const { directories, images, setPathSegments } = useContext(AppContext);
-
- const [viewedDirs, setViewedDirs] = useState(
- JSON.parse(localStorage.getItem('viewedDirs')) ?? []
- );
+ const { directories, images, setPathSegments, visitedDirs } =
+ useContext(AppContext);
const handleFolderClick = async (
path: string,
@@ -32,8 +29,6 @@ export function DirectoryList() {
setPathSegments((prevSegments) => [...prevSegments, name]);
- updateHistory(path);
-
images.current = sortImages(data);
navigate('/directoryContent', { replace: true });
@@ -46,20 +41,9 @@ export function DirectoryList() {
}
};
- const updateHistory = (visitedDir: string): void => {
- const newViewedDirs = [...viewedDirs, visitedDir];
-
- setViewedDirs(newViewedDirs);
-
- localStorage.setItem('viewedDirs', JSON.stringify(newViewedDirs));
- };
-
return (
<>
- setViewedDirs([])}
- />
+
void;
-}) {
+export function Navigation({ backPath }: { backPath: string }) {
const navigate = useNavigate();
const { isOpen, onOpen, onClose } = useDisclosure();
- const { pathSegments, setPathSegments } = useContext(AppContext);
+ const { pathSegments, setPathSegments, setVisitedDirs } =
+ useContext(AppContext);
const handleBackClick = (): void => {
setPathSegments((prevSegments) => {
@@ -46,8 +41,8 @@ export function Navigation({
};
const handleClearHistory = (): void => {
- localStorage.removeItem('viewedDirs');
- onClearHistory && onClearHistory();
+ localStorage.removeItem('visitedDirs');
+ setVisitedDirs([]);
onClose();
};
diff --git a/src/renderer/UploadButton.tsx b/src/renderer/UploadButton.tsx
deleted file mode 100644
index 9c3b519..0000000
--- a/src/renderer/UploadButton.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useState } from 'react';
-import { Box, Input, InputProps, Button } from '@chakra-ui/react';
-
-export function UploadButton({
- children,
- ...extra
-}: {
- children: React.ReactNode;
-} & InputProps) {
- const [hover, setHover] = useState(false);
-
- return (
-
- setHover(true)}
- onMouseLeave={() => setHover(false)}
- onClick={(e) => {
- e.currentTarget.value = ''; // clear the field each time it's clicked to allow for new selection
- }}
- webkitdirectory=""
- position="absolute"
- top={0}
- left={0}
- width="100%"
- height="100%"
- opacity={0}
- zIndex={1}
- {...extra}
- />
-
-
-
- );
-}
diff --git a/src/renderer/_data.ts b/src/renderer/_data.ts
index ca6f2bb..4102fcc 100644
--- a/src/renderer/_data.ts
+++ b/src/renderer/_data.ts
@@ -8,22 +8,33 @@ import { UseToastOptions } from '@chakra-ui/react';
import { UseStateCallbackT } from './useStateCallback';
export const AppContext = createContext<{
+ images: MutableRefObject;
+
pathSegments: string[];
setPathSegments: Dispatch>;
directories: DirContentT[];
setDirectories: UseStateCallbackT;
- images: MutableRefObject;
+ visitedDirs: string[];
+ setVisitedDirs: Dispatch>;
}>(null);
export const channels = {
LIST_DIR: 'LIST_DIR',
DELETE_FILE: 'DELETE_FILE',
UNDO_DELETE: 'UNDO_DELETE',
- EMPTY_TRASH: 'EMPTY_TRASH'
+ EMPTY_TRASH: 'EMPTY_TRASH',
+ MOVE_FILE: 'MOVE_FILE'
} as const;
+export const NAV_KEYS = {
+ next: 'arrowright',
+ prev: 'arrowleft',
+ delete: ' ',
+ pick: 'a'
+};
+
const SUCCESS_DURATION = 1_500;
export const ERROR_DURATION = 10_000;
diff --git a/src/renderer/_utils.ts b/src/renderer/_utils.ts
index 880a9bf..1d389cd 100644
--- a/src/renderer/_utils.ts
+++ b/src/renderer/_utils.ts
@@ -1,31 +1,9 @@
-const removeStartEndSlash = (path: string): string => {
+export const removeStartEndSlash = (path: string): string => {
if (path.startsWith('/')) path = path.slice(1);
if (path.endsWith('/')) path = path.slice(0, -1);
return path;
};
-// using the `webkitdirectory` attribute on the file input field lets us choose a folder
-// but the actual contents accessible are still the files inside the folder
-// and if the folder has several levels of nesting it's not possible to figure out the intended parent that the user wants to see
-// so we ask them to select the same folder again but this time using the directory picker window
-// the `showDirectoryPicker` API returns the proper directory name but not the absolute path (for security reasons)
-// this function glues together the info received from both sources to figure out the absolute path of the intended directory
-export const getRootDir = (
- path: string,
- dirName: string
-): {
- segments: string[];
- path: string;
-} => {
- const [rootPath] = path.split(dirName);
- path = rootPath + dirName;
-
- return {
- segments: removeStartEndSlash(path).split('/'),
- path
- };
-};
-
export const sortImages = (images: DirContentT[]): DirContentT[] =>
images.sort((a, b) => {
if (a.name < b.name) return -1;
@@ -33,15 +11,15 @@ export const sortImages = (images: DirContentT[]): DirContentT[] =>
return 0;
});
-export const isImage = ({
+export const isValidImage = ({
path,
extension
}: {
path?: string;
extension?: string;
}): boolean => {
- const VALID_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
- const fileExtension = extension || path.split('.').pop();
-
- return VALID_EXTENSIONS.includes(fileExtension.toLocaleLowerCase());
+ const fileExt = extension || path.split('.').pop();
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(
+ fileExt.toLocaleLowerCase()
+ );
};
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 4d8a3c8..3ffd021 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -16,13 +16,15 @@ declare global {
}
interface Window {
- electron: {
+ app: {
ipcRenderer: {
invoke(channel: ChannelT, args: unknown): Promise;
- removeAllListeners(channel: ChannelT): void;
};
+ openDialogPicker(): void;
+ onDialogPickerResult(
+ callback: (result: Electron.OpenDialogReturnValue) => void
+ ): void;
};
- showDirectoryPicker: () => Promise;
}
}