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; } }