From cadbf37e5de97a0ce40d4ac37035af899402a74d Mon Sep 17 00:00:00 2001 From: Amin Pakseresht <9244395+aminpaks@users.noreply.github.com> Date: Sun, 28 Feb 2021 20:40:02 -0500 Subject: [PATCH] Implement categories + sidebar (#21) --- src/api/category.ts | 74 ++++++ src/api/index.ts | 3 +- src/api/sync.ts | 5 +- src/api/torrents.ts | 1 + src/components/app.tsx | 10 +- src/components/auth/login.tsx | 6 +- src/components/data/category.ts | 18 ++ src/components/data/index.ts | 1 + src/components/layout/main-layout.tsx | 22 +- src/components/material-ui-core.ts | 10 + src/components/material-ui-icons.ts | 24 +- src/components/sidebar/categories-utils.tsx | 82 ++++++ src/components/sidebar/categories.tsx | 242 ++++++++++++++++++ .../sidebar/category-add-edit-dialog.tsx | 149 +++++++++++ .../sidebar/category-delete-dialog.tsx | 95 +++++++ src/components/sidebar/index.ts | 3 + src/components/sidebar/sidebar.tsx | 161 ++++++++++++ src/components/sidebar/types.ts | 9 + src/components/state/server-state.tsx | 205 +++++++++------ src/components/state/types.ts | 16 ++ src/components/state/ui-state.ts | 61 ++++- src/components/state/utils.tsx | 108 +++++++- src/components/torrents/columns.tsx | 1 + src/components/torrents/context-menu.tsx | 2 +- src/components/torrents/list.tsx | 2 +- src/components/torrents/renderers.tsx | 16 +- src/components/torrents/utils.tsx | 3 +- src/components/utils/events.ts | 43 ++++ src/constant.ts | 2 +- src/lang/en.json | 72 ++++++ src/types.ts | 4 - src/utils/{domHelpers.ts => dom-helpers.ts} | 4 + src/utils/index.ts | 2 +- 33 files changed, 1342 insertions(+), 114 deletions(-) create mode 100644 src/api/category.ts create mode 100644 src/components/data/category.ts create mode 100644 src/components/sidebar/categories-utils.tsx create mode 100644 src/components/sidebar/categories.tsx create mode 100644 src/components/sidebar/category-add-edit-dialog.tsx create mode 100644 src/components/sidebar/category-delete-dialog.tsx create mode 100644 src/components/sidebar/index.ts create mode 100644 src/components/sidebar/sidebar.tsx create mode 100644 src/components/sidebar/types.ts create mode 100644 src/components/state/types.ts create mode 100644 src/components/utils/events.ts rename src/utils/{domHelpers.ts => dom-helpers.ts} (60%) diff --git a/src/api/category.ts b/src/api/category.ts new file mode 100644 index 0000000..20e22dc --- /dev/null +++ b/src/api/category.ts @@ -0,0 +1,74 @@ +import { request } from './request'; +import { buildEndpointUrl } from './utils'; +import { getFormData } from '../utils'; + +export interface Category { + __internal: string | false; + name: string; + savePath: string; + hashList: string[]; +} + +export type CategoryCollection = Record; +export type CategoryOperationPayload = { + assign: { category: string; list: string[] }; + create: { category: string; savePath: string }; + edit: { category: string; savePath: string }; + delete: { categories: string[] }; +}; +export type CategoryOperation = keyof CategoryOperationPayload; +export type CategoryOperationOptions = { + [K in CategoryOperation]: [operation: K, options: CategoryOperationPayload[K]]; +}[CategoryOperation]; + +export type CategoryOperationResponse = [CategoryOperation, boolean]; + +export const apiV2CreateCategory = ({ category, savePath }: { category: string; savePath: string }) => + request(buildEndpointUrl(`/api/v2/torrents/createCategory`), { + method: 'POST', + body: getFormData({ + category, + savePath, + }), + }).then(() => ['create', true] as CategoryOperationResponse); + +export const apiV2EditCategory = ({ category, savePath }: { category: string; savePath: string }) => + request(buildEndpointUrl(`/api/v2/torrents/editCategory`), { + method: 'POST', + body: getFormData({ + category, + savePath, + }), + }).then(() => ['edit', true] as CategoryOperationResponse); + +export const apiV2DeleteCategory = ({ categories }: { categories: string[] }) => + request(buildEndpointUrl(`/api/v2/torrents/removeCategories`), { + method: 'POST', + body: getFormData({ + categories: categories.join('\n'), + }), + }).then(() => ['delete', true] as CategoryOperationResponse); + +export const apiV2AssignToCategory = ({ category, list }: { category: string; list: string[] }) => + request(buildEndpointUrl(`/api/v2/torrents/setCategory`), { + method: 'POST', + body: getFormData({ + category, + hashes: list.join('|'), + }), + }).then(() => ['assign', true] as CategoryOperationResponse); + +export const apiV2OperateCategory = (params: CategoryOperationOptions) => { + switch (params[0]) { + case 'assign': + return apiV2AssignToCategory(params[1]); + case 'create': + return apiV2CreateCategory(params[1]); + case 'edit': + return apiV2EditCategory(params[1]); + case 'delete': + return apiV2DeleteCategory(params[1]); + default: + throw new Error('Invalid operation'); + } +}; diff --git a/src/api/index.ts b/src/api/index.ts index 61ab6b0..414f731 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,7 @@ -export * from './app'; export * from './app-preferences'; +export * from './app'; export * from './auth'; +export * from './category'; export * from './request'; export * from './sync'; export * from './torrents'; diff --git a/src/api/sync.ts b/src/api/sync.ts index 0447a36..817081d 100644 --- a/src/api/sync.ts +++ b/src/api/sync.ts @@ -1,3 +1,4 @@ +import { Category, CategoryCollection } from './category'; import { requestJson } from './request'; import { Torrent } from './torrents'; import { buildEndpointUrl } from './utils'; @@ -21,8 +22,8 @@ export interface SyncMaindata { full_update: boolean; // Whether the response contains all the data or partial data torrents?: Record>; // Property: torrent hash, value: same as torrent list torrents_removed?: string[]; // List of hashes of torrents removed since last request - categories: unknown; // Info for categories added since last request - categories_removed?: unknown[]; // List of categories removed since last request + categories: CategoryCollection; // Info for categories added since last request + categories_removed?: string[]; // List of categories removed since last request tags?: unknown[]; // List of tags added since last request tags_removed?: unknown[]; // List of tags removed since last request server_state?: ServerState; // Global transfer info diff --git a/src/api/torrents.ts b/src/api/torrents.ts index 467b6d4..4c9f129 100644 --- a/src/api/torrents.ts +++ b/src/api/torrents.ts @@ -97,6 +97,7 @@ export type TorrentPrimitiveOperationOptions = { }[TorrentPrimitiveOperations]; export type TorrentKeys = keyof Torrent; +export type TorrentCollection = Record; export interface Torrent { added_on: number; // Time (Unix Epoch) when the torrent was added to the client diff --git a/src/components/app.tsx b/src/components/app.tsx index 0fcc1f5..326b981 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -2,12 +2,12 @@ import { FC } from 'react'; import { mStyles } from './common'; import { useAppVersionQuery } from './data'; import { MainLayout } from './layout'; +import Sidebar from './sidebar'; import TorrentsContainer from './torrents'; const useStyles = mStyles(() => ({ torrentContainer: { - width: '100%', - flex: '1 0 auto', + height: '100%', }, })); @@ -16,10 +16,8 @@ export const App: FC = () => { const { data: qbtVersion } = useAppVersionQuery(); return ( - -
- -
+ }> + ); }; diff --git a/src/components/auth/login.tsx b/src/components/auth/login.tsx index f34f2e6..de03a95 100644 --- a/src/components/auth/login.tsx +++ b/src/components/auth/login.tsx @@ -25,6 +25,10 @@ const LOGIN_PASSWORD = 'loginPassword'; const LOGIN_REMEMBER_ME = 'loginRememberMe'; const useStyles = mStyles(({ spacing }) => ({ + container: { + display: 'flex', + justifyContent: 'center', + }, loginRoot: { display: 'block', flex: '0 0 auto', @@ -72,7 +76,7 @@ export const Login: FC = () => { }, [isSuccess, appVersion]); return ( - } qbtVersion=""> + } qbtVersion="" className={classes.container}>
diff --git a/src/components/data/category.ts b/src/components/data/category.ts new file mode 100644 index 0000000..acbc8c2 --- /dev/null +++ b/src/components/data/category.ts @@ -0,0 +1,18 @@ +import { useMutation } from 'react-query'; +import { apiV2OperateCategory, CategoryOperationResponse } from '../../api'; +import { LazyReason } from '../../types'; + +export const useCategoryOperationsMutation = ( + { onSuccess, onError } = {} as { + onSuccess?: LazyReason | void, CategoryOperationResponse>; + onError?: LazyReason | void>; + } +) => { + const mutationObject = useMutation(apiV2OperateCategory, { + retry: false, + onSuccess, + onError, + }); + + return mutationObject; +}; diff --git a/src/components/data/index.ts b/src/components/data/index.ts index e11d844..e955598 100644 --- a/src/components/data/index.ts +++ b/src/components/data/index.ts @@ -1,3 +1,4 @@ export * from './app'; +export * from './category'; export * from './login'; export * from './torrents'; diff --git a/src/components/layout/main-layout.tsx b/src/components/layout/main-layout.tsx index 615a351..bdfef80 100644 --- a/src/components/layout/main-layout.tsx +++ b/src/components/layout/main-layout.tsx @@ -3,8 +3,9 @@ import AppHeader from '../header'; import { AppStatusBar } from '../app-statusbar'; import { mStyles } from '../common'; import { AppBar } from '../material-ui-core'; +import clsx from 'clsx'; -const useStyles = mStyles(({ spacing }) => ({ +const useStyles = mStyles(({ spacing, zIndex }) => ({ mainLayoutRoot: { width: '100vw', height: '100vh', @@ -14,27 +15,30 @@ const useStyles = mStyles(({ spacing }) => ({ mainLayoutContainer: { display: 'flex', flex: '1 0 auto', - alignItems: 'center', - flexDirection: 'column', - padding: spacing(2), - paddingBottom: 0, + alignItems: 'flex-start', }, - appBarContainer: { - padding: spacing(2), + appChildren: { + height: '100%', + flex: '1 0 auto', }, })); export const MainLayout: FC<{ header?: ReactElement; statusBar?: ReactElement; + sideBar?: ReactElement; qbtVersion: string; -}> = ({ header, statusBar, qbtVersion, children }) => { + className?: string; +}> = ({ header, statusBar, sideBar =
, qbtVersion, children, className }) => { const classes = useStyles(); return (
{header || } -
{children}
+
+ {sideBar} +
{children}
+
{statusBar || }
); diff --git a/src/components/material-ui-core.ts b/src/components/material-ui-core.ts index 6f85b73..bf8e8d2 100644 --- a/src/components/material-ui-core.ts +++ b/src/components/material-ui-core.ts @@ -1,5 +1,8 @@ import { PopoverOrigin } from '@material-ui/core/Popover/index'; import { SvgIconTypeMap } from '@material-ui/core/SvgIcon/index'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; import Alert from '@material-ui/lab/Alert'; import AppBar from '@material-ui/core/AppBar'; import Box from '@material-ui/core/Box'; @@ -11,6 +14,8 @@ import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogContentText from '@material-ui/core/DialogContentText'; import DialogTitle from '@material-ui/core/DialogTitle'; +import Divider from '@material-ui/core/Divider'; +import Drawer from '@material-ui/core/Drawer'; import FormControl from '@material-ui/core/FormControl'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormGroup from '@material-ui/core/FormGroup'; @@ -38,6 +43,7 @@ import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; export { + Accordion, Alert, AppBar, Box, @@ -49,6 +55,8 @@ export { DialogContent, DialogContentText, DialogTitle, + Divider, + Drawer, FormControl, FormControlLabel, FormGroup, @@ -76,4 +84,6 @@ export { SvgIconTypeMap, TextField, Typography, + AccordionDetails, + AccordionSummary, }; diff --git a/src/components/material-ui-icons.ts b/src/components/material-ui-icons.ts index ec45b91..8f2b75e 100644 --- a/src/components/material-ui-icons.ts +++ b/src/components/material-ui-icons.ts @@ -1,3 +1,4 @@ +import AddCircleIcon from '@material-ui/icons/AddCircle'; import AddIcon from '@material-ui/icons/Add'; import AlarmIcon from '@material-ui/icons/Alarm'; import AllInclusiveIcon from '@material-ui/icons/AllInclusive'; @@ -12,16 +13,22 @@ import CheckIcon from '@material-ui/icons/Check'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import ClassIcon from '@material-ui/icons/Class'; +import CloseIcon from '@material-ui/icons/Close'; import DeleteIcon from '@material-ui/icons/Delete'; import DoneIcon from '@material-ui/icons/Done'; +import EditIcon from '@material-ui/icons/Edit'; import FastForwardIcon from '@material-ui/icons/FastForward'; import FeaturedPlayListIcon from '@material-ui/icons/FeaturedPlayList'; import FileCopyIcon from '@material-ui/icons/FileCopy'; import FindInPageIcon from '@material-ui/icons/FindInPage'; import FirstPageIcon from '@material-ui/icons/FirstPage'; +import FolderIcon from '@material-ui/icons/Folder'; import FolderOpenIcon from '@material-ui/icons/FolderOpen'; import GetAppIcon from '@material-ui/icons/GetApp'; import GrainIcon from '@material-ui/icons/Grain'; +import HelpIcon from '@material-ui/icons/Help'; +import HelpOutlineIcon from '@material-ui/icons/HelpOutline'; +import HighlightOffIcon from '@material-ui/icons/HighlightOff'; import LabelImportantIcon from '@material-ui/icons/LabelImportant'; import LastPageIcon from '@material-ui/icons/LastPage'; import LinkIcon from '@material-ui/icons/Link'; @@ -36,20 +43,23 @@ import PowerOffIcon from '@material-ui/icons/PowerOff'; import PublishIcon from '@material-ui/icons/Publish'; import RemoveIcon from '@material-ui/icons/Remove'; import ReorderIcon from '@material-ui/icons/Reorder'; +import SearchIcon from '@material-ui/icons/Search'; +import SettingsIcon from '@material-ui/icons/Settings'; import ShareIcon from '@material-ui/icons/Share'; import SpeedIcon from '@material-ui/icons/Speed'; import StorageIcon from '@material-ui/icons/Storage'; import SubtitlesIcon from '@material-ui/icons/Subtitles'; +import TrackChangesIcon from '@material-ui/icons/TrackChanges'; import TrendingDownIcon from '@material-ui/icons/TrendingDown'; import TrendingUpIcon from '@material-ui/icons/TrendingUp'; import VerticalAlignBottomIcon from '@material-ui/icons/VerticalAlignBottom'; import VerticalAlignTopIcon from '@material-ui/icons/VerticalAlignTop'; +import ViewListIcon from '@material-ui/icons/ViewList'; import WifiIcon from '@material-ui/icons/Wifi'; import WifiOffIcon from '@material-ui/icons/WifiOff'; -import SearchIcon from '@material-ui/icons/Search'; export { - SearchIcon, + AddCircleIcon, AddIcon, AlarmIcon, AllInclusiveIcon, @@ -64,16 +74,22 @@ export { ChevronLeftIcon, ChevronRightIcon, ClassIcon, + CloseIcon, DeleteIcon, DoneIcon, + EditIcon, FastForwardIcon, FeaturedPlayListIcon, FileCopyIcon, FindInPageIcon, FirstPageIcon, + FolderIcon, FolderOpenIcon, GetAppIcon, GrainIcon, + HelpIcon, + HelpOutlineIcon, + HighlightOffIcon, LabelImportantIcon, LastPageIcon, LinkIcon, @@ -88,14 +104,18 @@ export { PublishIcon, RemoveIcon, ReorderIcon, + SearchIcon, + SettingsIcon, ShareIcon, SpeedIcon, StorageIcon, SubtitlesIcon, + TrackChangesIcon, TrendingDownIcon, TrendingUpIcon, VerticalAlignBottomIcon, VerticalAlignTopIcon, + ViewListIcon, WifiIcon, WifiOffIcon, }; diff --git a/src/components/sidebar/categories-utils.tsx b/src/components/sidebar/categories-utils.tsx new file mode 100644 index 0000000..bb7e18a --- /dev/null +++ b/src/components/sidebar/categories-utils.tsx @@ -0,0 +1,82 @@ +import { FormattedMessage } from 'react-intl'; +import { Category } from '../../api'; +import { + CloseIcon, + DeleteIcon, + EditIcon, + PauseIcon, + PlayArrowIcon, + TrackChangesIcon, +} from '../material-ui-icons'; +import { CategoryAction } from './types'; + +export const getCategoryLabels = (action: CategoryAction, selectionCount?: number) => { + switch (action) { + case 'edit': + return ; + case 'delete': + return ; + case 'applyToItems': + return selectionCount === 0 ? ( + + ) : ( + {chunk}, count: selectionCount ?? 0 }} + /> + ); + case 'resumeItems': + return ; + case 'pauseItems': + return ; + case 'deleteItems': + return ; + default: + return null; + } +}; + +export const getCategoryIcon = (action: CategoryAction) => { + switch (action) { + case 'edit': + return ; + case 'delete': + return ; + case 'applyToItems': + return ; + case 'resumeItems': + return ; + case 'pauseItems': + return ; + case 'deleteItems': + return ; + default: + return null; + } +}; + +export const getCategoryDisableStatus = ({ + category, + action, + selectionLength, + counts, +}: { + category?: Category; + action: CategoryAction; + selectionLength: number; + counts: number; +}) => { + switch (action) { + case 'edit': + case 'delete': + return Boolean(category?.__internal || false); + case 'applyToItems': + return selectionLength <= 0; + case 'resumeItems': + case 'pauseItems': + case 'deleteItems': + return counts <= 0; + default: + return true; + } +}; diff --git a/src/components/sidebar/categories.tsx b/src/components/sidebar/categories.tsx new file mode 100644 index 0000000..24b163c --- /dev/null +++ b/src/components/sidebar/categories.tsx @@ -0,0 +1,242 @@ +import { IconButton, Popover } from '@material-ui/core'; +import { useRef, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Category } from '../../api'; +import { mStyles } from '../common'; +import { useCategoryOperationsMutation, useTorrentsOperationMutation } from '../data'; +import { + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemSecondaryAction, + Divider, +} from '../material-ui-core'; +import { AddCircleIcon, FolderIcon, FolderOpenIcon, MoreVertIcon } from '../material-ui-icons'; +import { useNotifications } from '../notifications'; +import { useCategories, useTorrentSortFilterState, useUiState } from '../state'; +import { useClickOutsideElement, useDocumentEvents } from '../utils/events'; +import { getCategoryDisableStatus, getCategoryIcon, getCategoryLabels } from './categories-utils'; +import { CategoryAddEditDialog } from './category-add-edit-dialog'; +import { CategoryDeleteDialog } from './category-delete-dialog'; +import { CategoryAction } from './types'; + +const useStyles = mStyles(() => ({ + listRoot: { + '& .MuiListItemSecondaryAction-root': { + '&:hover': { + '& .ModifyButton': { + opacity: 1, + }, + }, + '& .ModifyButton': { + ['@media (min-height: 400px)']: { + opacity: 0, + }, + transition: '140ms ease opacity', + }, + }, + }, + listItemRoot: { + '& > *': { + pointerEvents: 'none', + }, + '&:hover, &.Mui-selected': { + '& + div .ModifyButton': { + opacity: 1, + }, + }, + }, + contextMenuItem: { + '& .MuiListItemIcon-root': { + minWidth: 30, + }, + }, +})); + +const contextMenuActions: (CategoryAction | 'divider')[] = [ + 'edit', + 'delete', + 'divider', + 'applyToItems', + 'divider', + 'resumeItems', + 'pauseItems', + 'deleteItems', +]; + +const INITIAL_STATE_VALUE = { anchor: null as Element | null, category: null as Category | null }; + +export const Categories = () => { + const classes = useStyles(); + const listRef = useRef(null); + const [state, setState] = useState(INITIAL_STATE_VALUE); + const categoryCollection = useCategories(); + const [ + { torrentListSelection }, + { updateCategoryAddEditDialogOpen, updateCategoryDeleteDialogOpen, updateDeleteConfirmationDialogIsOpen }, + ] = useUiState(); + const [{ category: selectedCategoryName }, updateFilter] = useTorrentSortFilterState(); + const { create: createNotification } = useNotifications(); + + useClickOutsideElement(() => { + setState(s => ({ ...s, anchor: null })); + }, listRef.current); + + useDocumentEvents( + ({ key }) => { + if (key === 'Escape') { + setState(s => ({ ...s, anchor: null })); + } + }, + ['keyup'] + ); + + const { mutate: update } = useCategoryOperationsMutation({ + onSuccess: () => {}, + }); + const { mutate: torrentsOperates } = useTorrentsOperationMutation(); + + const handleContextItemClick = ( + action: CategoryAction, + { category, list }: { category: string; list: string[] } + ) => { + switch (action) { + case 'create': { + updateCategoryAddEditDialogOpen({ + value: true, + type: 'add', + }); + break; + } + case 'edit': { + updateCategoryAddEditDialogOpen({ + value: true, + type: 'edit', + category: categoryCollection[category], + }); + break; + } + case 'delete': { + updateCategoryDeleteDialogOpen({ value: true, categories: [category] }); + break; + } + case 'applyToItems': { + update(['assign', { category, list }]); + break; + } + case 'resumeItems': { + torrentsOperates({ list, params: ['resume'] }); + break; + } + case 'pauseItems': { + torrentsOperates({ list, params: ['pause'] }); + break; + } + case 'deleteItems': { + updateDeleteConfirmationDialogIsOpen({ value: true, list }); + } + default: + break; + } + }; + + const categories = Object.values(categoryCollection); + + return ( + <> + + {categories.map(category => { + const { __internal, name, hashList } = category; + const categoryId = __internal ? __internal : name; + const categoryName = name; + + return ( + { + updateFilter({ category: categoryId }); + }} + > + + {selectedCategoryName === categoryId ? : } + + + {categoryName} ({hashList.length}) + + + {__internal === '__all__' ? ( + { + updateCategoryAddEditDialogOpen({ value: true, type: 'add' }); + }} + > + + + ) : ( + { + setState({ anchor: target as Element, category }); + }} + > + + + )} + + + ); + })} + + + + {contextMenuActions.map((action, idx) => + action === 'divider' ? ( + + ) : ( + { + if (!state.category) { + return createNotification({ + message: , + severity: 'error', + }); + } + const { __internal, hashList: categoryItems, name } = state.category; + const category = __internal ? '' : name; + const list = action === 'applyToItems' ? torrentListSelection : categoryItems; + handleContextItemClick(action, { category, list }); + setState(INITIAL_STATE_VALUE); + }} + > + {getCategoryIcon(action)} + {getCategoryLabels(action, torrentListSelection.length)} + + ) + )} + + + + + + ); +}; diff --git a/src/components/sidebar/category-add-edit-dialog.tsx b/src/components/sidebar/category-add-edit-dialog.tsx new file mode 100644 index 0000000..6f67653 --- /dev/null +++ b/src/components/sidebar/category-add-edit-dialog.tsx @@ -0,0 +1,149 @@ +import { ChangeEventHandler, useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useCategoryOperationsMutation } from '../data'; +import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField } from '../material-ui-core'; +import { useNotifications } from '../notifications'; +import { useUiState } from '../state'; + +export const CategoryAddEditDialog = () => { + const [ + { + category: { + addEditDialog: { isOpen, category: currentCategory }, + }, + }, + { updateCategoryAddEditDialogOpen }, + ] = useUiState(); + const { create: createNotification } = useNotifications(); + const [categoryState, setCategoryState] = useState({ + categoryName: '', + savePath: '', + }); + const isCreating = !currentCategory; + + const { mutate: operate, isLoading } = useCategoryOperationsMutation({ + onSuccess: result => { + if (result) { + updateCategoryAddEditDialogOpen({ value: false }); + } + if (isCreating) { + createNotification({ + message: result ? ( + {chunk}, + category: categoryState.categoryName, + }} + /> + ) : ( + + ), + severity: result ? 'info' : 'error', + }); + } else { + createNotification({ + message: result ? ( + {chunk}, + category: categoryState.categoryName, + }} + /> + ) : ( + + ), + severity: result ? 'info' : 'error', + }); + } + }, + }); + + useEffect(() => { + if (isOpen) { + if (currentCategory) { + const { name: categoryName, savePath } = currentCategory; + setCategoryState({ categoryName, savePath }); + } else { + setCategoryState({ categoryName: '', savePath: '' }); + } + } + }, [isOpen]); + + const handleCategoryAddEdit = () => { + const { categoryName: category, savePath } = categoryState; + operate([isCreating ? 'create' : 'edit', { category, savePath }]); + }; + + const handleInputChange: ChangeEventHandler = ({ target }) => { + setCategoryState(s => ({ ...s, [target.name]: target.value })); + }; + + const handleDialogClose = () => { + updateCategoryAddEditDialogOpen({ value: false }); + }; + + return ( + + + {isCreating ? ( + + ) : ( + + )} + + + } + value={categoryState.categoryName} + style={{ minWidth: 400 }} + disabled={!isCreating} + onKeyDown={({ key }) => { + if (key === 'Enter') { + handleCategoryAddEdit(); + } + }} + onChange={handleInputChange} + /> + } + value={categoryState.savePath} + style={{ minWidth: 400 }} + onKeyDown={({ key }) => { + if (key === 'Enter') { + handleCategoryAddEdit(); + } + }} + onChange={handleInputChange} + /> + + + + + + + + ); +}; diff --git a/src/components/sidebar/category-delete-dialog.tsx b/src/components/sidebar/category-delete-dialog.tsx new file mode 100644 index 0000000..c4e919f --- /dev/null +++ b/src/components/sidebar/category-delete-dialog.tsx @@ -0,0 +1,95 @@ +import { FormattedMessage } from 'react-intl'; +import { useCategoryOperationsMutation } from '../data'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogContentText, + DialogActions, + Button, +} from '../material-ui-core'; +import { useNotifications } from '../notifications'; +import { useUiState } from '../state'; + +export const CategoryDeleteDialog = () => { + const [ + { + category: { + deleteConfirmationDialog: { isOpen, categories }, + }, + }, + { updateCategoryDeleteDialogOpen }, + ] = useUiState(); + const { create: createNotification } = useNotifications(); + + const { mutate: operate, isLoading } = useCategoryOperationsMutation({ + onSuccess: result => { + if (result) { + updateCategoryDeleteDialogOpen({ value: false }); + } + createNotification({ + message: result ? ( + {chunk}, + category: categories[0], + }} + /> + ) : ( + + ), + severity: result ? 'info' : 'error', + }); + }, + }); + return ( + { + updateCategoryDeleteDialogOpen({ value: false }); + }} + > + + + + + + {chunk}, + item: categories[0], + }} + /> + + + + + + + + + ); +}; diff --git a/src/components/sidebar/index.ts b/src/components/sidebar/index.ts new file mode 100644 index 0000000..4800ffe --- /dev/null +++ b/src/components/sidebar/index.ts @@ -0,0 +1,3 @@ +import { Sidebar } from './sidebar'; + +export default Sidebar; diff --git a/src/components/sidebar/sidebar.tsx b/src/components/sidebar/sidebar.tsx new file mode 100644 index 0000000..8a1333c --- /dev/null +++ b/src/components/sidebar/sidebar.tsx @@ -0,0 +1,161 @@ +import clsx from 'clsx'; +import produce from 'immer'; +import { useCallback, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { mStyles } from '../common'; +import { Drawer, Accordion, AccordionDetails, AccordionSummary, Typography } from '../material-ui-core'; +import { ViewListIcon } from '../material-ui-icons'; +import { Categories } from './categories'; +import { colorAlpha, storageGet, storageSet, unsafeMutateDefaults } from '../../utils'; + +const SIDEBAR_SECTION_STATUS_KEY = 'sidebarSectionStatus'; +const defaultSectionStatus = unsafeMutateDefaults>({ category: true }); + +export const Sidebar = () => { + const classes = useStyles(); + const [sectionStatus, setSectionStatus] = useState( + defaultSectionStatus(storageGet(SIDEBAR_SECTION_STATUS_KEY, {} as Record<'category', boolean>)) + ); + const getHandleSectionToggle = useCallback((key: keyof typeof sectionStatus) => { + return () => { + setSectionStatus(s => { + const updatedState = produce(s, draft => { + draft[key] = !draft[key]; + }); + + return storageSet(SIDEBAR_SECTION_STATUS_KEY, updatedState); + }); + }; + }, []); + + const isAllSectionsCollapsed = !Object.values(sectionStatus).reduce((acc, b) => b && acc, true); + + return ( + + + + + + + + + + + + + + ); +}; + +const useStyles = mStyles(({ spacing, palette }) => ({ + container: { + minWidth: 200, + flex: '0 0 200px', + display: 'flex', + borderRight: '3px solid #000', + }, + drawerClosed: { + width: spacing(6) + 1, + '& $drawerPaper': { + overflow: 'hidden', + }, + }, + drawerRoot: { + minHeight: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + overflowX: 'hidden', + }, + drawerPaper: { + position: 'relative', + backgroundColor: 'inherit', + whiteSpace: 'nowrap', + flex: '1 1 0px', + zIndex: 1, + }, + accordionRoot: { + backgroundColor: 'inherit', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + '&$accordionExpanded': { + marginTop: 'auto', + marginBottom: 'auto', + }, + '& .MuiListItem-root': { + position: 'relative', + '&::before': { + left: spacing(6.8), + bottom: 0, + width: `calc(100% - ${spacing(6.8)}px)`, + height: 1, + display: 'block', + content: '""', + position: 'absolute', + backgroundColor: colorAlpha(palette.divider, 0.08).toString(), + }, + }, + '& .shouldShorten': { + display: 'inline-block', + overflow: 'hidden', + textOverflow: 'ellipsis', + verticalAlign: 'text-bottom', + maxWidth: spacing(24), + }, + }, + accordionExpanded: {}, + accordionDetailsRoot: { + padding: 0, + '& .MuiListItemIcon-root': { + minWidth: spacing(4.8), + }, + }, + accordionSummaryRoot: { + display: 'flex', + alignItems: 'center', + padding: 0, + paddingLeft: spacing(1), + paddingRight: spacing(1), + borderBottom: `1px solid ${palette.divider}`, + + '&$accordionSummaryExpanded': { + minHeight: spacing(6), + }, + '& $accordionSummaryExpanded': { + margin: 0, + marginRight: spacing(2), + }, + '& .MuiSvgIcon-root': { + marginLeft: spacing(0.5), + marginRight: spacing(3), + transition: '140ms ease-in-out margin-left', + flex: '0 0 auto', + }, + '& .MuiAccordionSummary-content': { + maxWidth: '100%', + '& .MuiTypography-root': { + flex: '1 1 0px', + }, + }, + }, + accordionSummaryExpanded: {}, +})); diff --git a/src/components/sidebar/types.ts b/src/components/sidebar/types.ts new file mode 100644 index 0000000..11df5ed --- /dev/null +++ b/src/components/sidebar/types.ts @@ -0,0 +1,9 @@ +export type CategoryAction = + | 'create' + | 'edit' + | 'delete' + | 'applyToItems' + | 'resumeItems' + | 'pauseItems' + | 'deleteItems' + | 'invalid'; diff --git a/src/components/state/server-state.tsx b/src/components/state/server-state.tsx index 6572f73..71131b7 100644 --- a/src/components/state/server-state.tsx +++ b/src/components/state/server-state.tsx @@ -1,22 +1,31 @@ import produce from 'immer'; -import fuzzysort from 'fuzzysort'; -import { createContext, FC, useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react'; -import { apiV2SyncMaindata, ServerState, SyncMaindata, Torrent } from '../../api'; -import { TorrentCollection } from '../../types'; +import { useIntl } from 'react-intl'; +import { createContext, FC, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { storageGet, storageSet, tryCatch, unsafeMutateDefaults } from '../../utils'; - -type TorrentKeys = keyof Torrent; -interface SortFilterStateValue { - column: TorrentKeys; - asc: boolean; - search: string; -} -type SortFilterState = [SortFilterStateValue, SortFilterHandler]; -type SortFilterHandler = (payload: { column?: TorrentKeys; search?: string }) => void; +import { CategoryState, SortFilterHandler, SortFilterState, SortFilterStateValue } from './types'; +import { + buildCategory, + getCategoryName, + getInitialCategories, + sortAndFilter, + toHashList, + getNormalizedCategory, +} from './utils'; +import { + apiV2SyncMaindata, + Category, + ServerState, + SyncMaindata, + Torrent, + TorrentCollection, + TorrentKeys, +} from '../../api'; +import { useUiState } from './ui-state'; const TORRENT_SORT_KEY = 'torrentListSortFilter'; const initialServerState = {} as ServerState; +const initialCategoryState: CategoryState = {}; const initialTorrentsState = { collection: {}, hashList: [], viewHashList: [] } as { collection: TorrentCollection; hashList: string[]; @@ -26,9 +35,11 @@ const initialTorrentSortFilterState: SortFilterStateValue = { column: 'priority', asc: true, search: '', + category: '__all__', }; const ServerContext = createContext(initialServerState); +const CategoryContext = createContext(initialCategoryState); const TorrentsContext = createContext(initialTorrentsState); const TorrentHashListContext = createContext(initialTorrentsState.hashList); const TorrentViewHashListContext = createContext(initialTorrentsState.viewHashList); @@ -37,67 +48,12 @@ const TorrentSortFilterContext = createContext(([ undefined, ] as unknown) as SortFilterState); -const toString = (i: boolean | number | string) => { - if (typeof i === 'string') { - return i.toLowerCase(); - } else if (typeof i === 'number') { - return i.toFixed(4).padStart(50, '0'); - } - return String(i); -}; - -const sortByPriorityOrName = (x: Torrent, y: Torrent) => { - if (x.priority === 0 && y.priority === 0) { - return toString(x.name).localeCompare(toString(y.name)); - } else if (x.priority === 0 && y.priority !== 0) { - return 1; - } else if (x.priority !== 0 && y.priority === 0) { - return -1; - } - return toString(x.priority).localeCompare(toString(y.priority)); -}; - -const sortTorrent = (l: Torrent[], sortBy: TorrentKeys = 'priority', asc = true) => { - l.sort((x, y) => { - const xSortValue = x[sortBy]; - const ySortValue = y[sortBy]; - - if (sortBy === 'priority') { - return sortByPriorityOrName(x, y); - } - - let result = toString(xSortValue).localeCompare(toString(ySortValue)); - - if (result === 0) { - return sortByPriorityOrName(x, y); - } - - return result; - }); - - if (asc === false) { - l.reverse(); - } - return l; -}; - -const toHashList = (l: Torrent[]) => l.map(({ hash }) => hash); - -const sortAndFilter = (state: SortFilterStateValue, l: Torrent[]): Torrent[] => { - const { column, asc, search } = state; - - if (search) { - const fuzzyResults = fuzzysort.go(search, l, { key: 'name' }); - return fuzzyResults.map(({ obj }) => obj); - } - - return sortTorrent(l, column, asc); -}; - export const AppContextProvider: FC = ({ children }) => { + const intl = useIntl(); const referenceId = useRef(0); const [sortFilterRefId, setSortFilterRefId] = useState(0); const [serverState, setServerState] = useState(initialServerState); + const [categoryState, setCategoryState] = useState(initialCategoryState); const [torrentsState, setTorrentsState] = useState(initialTorrentsState); const [torrentSortFilterState, setTorrentSortState] = useState( unsafeMutateDefaults(initialTorrentSortFilterState)( @@ -105,10 +61,13 @@ export const AppContextProvider: FC = ({ children }) => { ) ); const torrentSortFilterStateRef = useRef(torrentSortFilterState); + const categoryStateRef = useRef(categoryState); + + const [, { updateTorrentSelectionList }] = useUiState(); const handleListSortFilter = useCallback(payload => { setTorrentSortState(s => { - const { column, search } = payload; + const { column, search, category } = payload; const updatedSort = produce(s, draft => { if (column) { @@ -122,7 +81,11 @@ export const AppContextProvider: FC = ({ children }) => { if (search != null) { draft.search = search; } + if (category != null) { + draft.category = category; + } }); + updateTorrentSelectionList({ type: 'only', list: [] }); return storageSet(TORRENT_SORT_KEY, updatedSort); }); @@ -134,10 +97,19 @@ export const AppContextProvider: FC = ({ children }) => { async function fetchMaindata() { const sync = await tryCatch(() => apiV2SyncMaindata(referenceId.current), {} as SyncMaindata); - const { rid, full_update, torrents = {}, torrents_removed, server_state } = sync; + const { + rid, + full_update, + torrents = {}, + torrents_removed, + server_state, + categories, + categories_removed, + } = sync; if (rid) { referenceId.current = rid; + if (torrents_removed && torrents_removed.length > 0) { setSortFilterRefId(Date.now()); setTorrentsState(s => @@ -151,6 +123,7 @@ export const AppContextProvider: FC = ({ children }) => { } const torrentHashes = Object.keys(torrents); + const updatedCategories: Record = {}; if (full_update) { // Mutate items and update hash property for (const hash in torrents) { @@ -172,11 +145,23 @@ export const AppContextProvider: FC = ({ children }) => { const torrent = torrents[hash]; if (currentItem) { Object.entries(torrent).forEach(item => { - const [key, value] = item as [TorrentKeys, never]; + const [key, value] = item as [TorrentKeys, unknown]; + if (key === 'category' && currentItem.category !== value && typeof value === 'string') { + const oldCategoryName = getNormalizedCategory(currentItem.category); + const newCategoryName = getNormalizedCategory(value); + const newCategorySet = updatedCategories[newCategoryName] || []; + const oldCategorySet = updatedCategories[oldCategoryName] || []; + + newCategorySet.push([currentItem.hash, true]); + oldCategorySet.push([currentItem.hash, false]); + + updatedCategories[oldCategoryName] = oldCategorySet; + updatedCategories[newCategoryName] = newCategorySet; + } if (key === torrentSortFilterStateRef.current.column) { shouldUpdateHashOrder = true; } - currentItem[key] = value; + currentItem[key] = value as never; }); } else { shouldUpdateHashOrder = true; @@ -192,6 +177,64 @@ export const AppContextProvider: FC = ({ children }) => { }); } + // Set category state + if (full_update) { + const updatedInitialCategoryState: CategoryState = Object.values( + torrents as Record + ).reduce((acc, torrent) => { + const { hash, category: categoryStr } = torrent; + + const category: Category | undefined = categoryStr !== '' ? acc[categoryStr] : acc['__none__']; + + if (category) { + category.hashList.push(hash); + } + acc['__all__'].hashList.push(hash); + return acc; + }, getInitialCategories(intl, categories)); + + setCategoryState(updatedInitialCategoryState); + } else { + if (Object.keys(updatedCategories).length > 0 || categories || categories_removed) { + setCategoryState(s => { + const value = produce(s, draft => { + if (categories_removed) { + categories_removed.forEach(categoryName => { + delete draft[categoryName]; + }); + } + const oldCategories = Object.values(draft); + const newCategories = Object.entries(categories || {}).map(([name, category]) => { + const prev = draft[name] ? draft[name] : ({} as Category); + return buildCategory({ + ...prev, + ...category, + }); + }); + oldCategories.concat(newCategories).forEach(category => { + const categoryName = getCategoryName(category); + const updates = updatedCategories[categoryName]; + if (updates && updates.length > 0) { + updates.forEach(([hash, isAdding]) => { + const currentIndex = category.hashList.indexOf(hash); + if (isAdding && currentIndex < 0) { + category.hashList.push(hash); + } else if (!isAdding && currentIndex >= 0) { + category.hashList.splice(currentIndex, 1); + } + }); + } + draft[categoryName] = category; + }); + }); + + setSortFilterRefId(Date.now()); + + return value; + }); + } + } + // Update Server state if (server_state) { if (full_update) { @@ -224,12 +267,18 @@ export const AppContextProvider: FC = ({ children }) => { }; }, []); + useEffect(() => { + categoryStateRef.current = categoryState; + }); + useEffect(() => { torrentSortFilterStateRef.current = torrentSortFilterState; setTorrentsState(s => { const result = produce(s, draft => { - draft.viewHashList = toHashList(sortAndFilter(torrentSortFilterState, Object.values(s.collection))); + draft.viewHashList = toHashList( + sortAndFilter(torrentSortFilterState, Object.values(s.collection), categoryStateRef.current) + ); }); return result; }); @@ -241,7 +290,7 @@ export const AppContextProvider: FC = ({ children }) => { - {children} + {children} @@ -269,3 +318,7 @@ export const useTorrentViewList = () => { export const useTorrentSortFilterState = () => { return useContext(TorrentSortFilterContext); }; + +export const useCategories = () => { + return useContext(CategoryContext); +}; diff --git a/src/components/state/types.ts b/src/components/state/types.ts new file mode 100644 index 0000000..57a7ebf --- /dev/null +++ b/src/components/state/types.ts @@ -0,0 +1,16 @@ +import { Category, TorrentKeys } from '../../api'; + +export type CategoryState = Record; + +export interface SortFilterStateValue { + column: TorrentKeys; + asc: boolean; + search: string; + category: string; +} +export type SortFilterState = [SortFilterStateValue, SortFilterHandler]; +export type SortFilterHandler = (payload: { + column?: TorrentKeys; + search?: string; + category?: string; +}) => void; diff --git a/src/components/state/ui-state.ts b/src/components/state/ui-state.ts index 3d2b8cd..c14ea57 100644 --- a/src/components/state/ui-state.ts +++ b/src/components/state/ui-state.ts @@ -1,4 +1,5 @@ import produce from 'immer'; +import { Category } from '../../api'; import { actionCreator, ActionUnion, buildCustomContext } from '../utils'; export interface UiState { @@ -25,6 +26,16 @@ export interface UiState { addNewDialog: { isOpen: boolean; }; + category: { + addEditDialog: { + isOpen: boolean; + category: Pick | null; + }; + deleteConfirmationDialog: { + isOpen: boolean; + categories: string[]; + }; + }; } const initialUiState: UiState = { @@ -51,6 +62,16 @@ const initialUiState: UiState = { addNewDialog: { isOpen: false, }, + category: { + addEditDialog: { + isOpen: false, + category: null, + }, + deleteConfirmationDialog: { + isOpen: false, + categories: [], + }, + }, }; const updateTorrentSelectionList = actionCreator('torrentList.updateSelection')< @@ -63,7 +84,9 @@ const updateTorrentSelectionList = actionCreator('torrentList.updateSelection')< const updateContextMenuIsOpen = actionCreator('contextMenu.isOpen')<{ value: boolean }>(); -const updateDeleteConfirmationDialogIsOpen = actionCreator('deleteConfirmation.isOpen')<{ value: boolean }>(); +const updateDeleteConfirmationDialogIsOpen = actionCreator('deleteConfirmation.isOpen')< + { value: false } | { value: true; list?: string[] } +>(); const updateSetLocationDialogIsOpen = actionCreator('setLocation.isOpen')<{ value: boolean }>(); @@ -81,6 +104,18 @@ const updateShareLimitDialogOpen = actionCreator('limitShareDialog.isOpen')<{ va const updateAddNewDialogOpen = actionCreator('addNewDialog.isOpen')<{ value: boolean }>(); +const updateCategoryDeleteDialogOpen = actionCreator('category.deleteDialog.isOpen')< + | { + value: true; + categories: string[]; + } + | { value: false } +>(); + +const updateCategoryAddEditDialogOpen = actionCreator('category.addEditDialog.isOpen')< + { value: false } | { value: true; type: 'add' } | { value: true; type: 'edit'; category: Category } +>(); + export const uiActions = { updateTorrentSelectionList, updateContextMenuIsOpen, @@ -90,6 +125,8 @@ export const uiActions = { updateLimitRateDialogOpen, updateShareLimitDialogOpen, updateAddNewDialogOpen, + updateCategoryDeleteDialogOpen, + updateCategoryAddEditDialogOpen, }; export type UiActions = ActionUnion; @@ -133,6 +170,9 @@ const reducer = produce((draft: UiState, action: UiActions) => { case 'deleteConfirmation.isOpen': draft.deleteConfirmation.isOpen = action.payload.value; + if (action.payload.value === true && action.payload.list) { + draft.torrentListSelection = action.payload.list; + } break; case 'setLocation.isOpen': @@ -159,6 +199,25 @@ const reducer = produce((draft: UiState, action: UiActions) => { draft.addNewDialog.isOpen = action.payload.value; break; + case 'category.deleteDialog.isOpen': + draft.category.deleteConfirmationDialog.isOpen = action.payload.value; + if (action.payload.value === true) { + draft.category.deleteConfirmationDialog.categories = action.payload.categories; + } else { + draft.category.deleteConfirmationDialog.categories = []; + } + break; + + case 'category.addEditDialog.isOpen': + draft.category.addEditDialog.isOpen = action.payload.value; + if (action.payload.value === true) { + if (action.payload.type === 'add') { + draft.category.addEditDialog.category = null; + } else { + draft.category.addEditDialog.category = action.payload.category; + } + } + default: break; } diff --git a/src/components/state/utils.tsx b/src/components/state/utils.tsx index 7b9171f..772f19c 100644 --- a/src/components/state/utils.tsx +++ b/src/components/state/utils.tsx @@ -1,7 +1,9 @@ -import { FormattedMessage } from 'react-intl'; -import { ConnectionStatus, Torrent } from '../../api'; +import fuzzysort from 'fuzzysort'; +import { FormattedMessage, IntlShape } from 'react-intl'; +import { Category, ConnectionStatus, Torrent, TorrentKeys } from '../../api'; import { usePersistentMemo } from '../utils'; import { useTorrentsState } from './server-state'; +import { CategoryState, SortFilterStateValue } from './types'; import { useUiState } from './ui-state'; export const getConnectionStatusString = (status: ConnectionStatus) => { @@ -27,3 +29,105 @@ export const usePersistentSelectedTorrents = (): [Torrent[], (shouldPersist?: bo return [torrents, persist, hashList]; }; + +const toString = (i: boolean | number | string) => { + if (typeof i === 'string') { + return i.toLowerCase(); + } else if (typeof i === 'number') { + return i.toFixed(4).padStart(50, '0'); + } + return String(i); +}; + +export const sortByPriorityOrName = (x: Torrent, y: Torrent) => { + if (x.priority === 0 && y.priority === 0) { + return toString(x.name).localeCompare(toString(y.name)); + } else if (x.priority === 0 && y.priority !== 0) { + return 1; + } else if (x.priority !== 0 && y.priority === 0) { + return -1; + } + return toString(x.priority).localeCompare(toString(y.priority)); +}; + +export const sortTorrent = (l: Torrent[], sortBy: TorrentKeys = 'priority', asc = true) => { + l.sort((x, y) => { + const xSortValue = x[sortBy]; + const ySortValue = y[sortBy]; + + if (sortBy === 'priority') { + return sortByPriorityOrName(x, y); + } + + let result = toString(xSortValue).localeCompare(toString(ySortValue)); + + if (result === 0) { + return sortByPriorityOrName(x, y); + } + + return result; + }); + + if (asc === false) { + l.reverse(); + } + return l; +}; + +export const toHashList = (l: Torrent[]) => l.map(({ hash }) => hash); + +export const getNormalizedCategory = (cat: string) => (cat ? cat : '__none__'); + +export const sortAndFilter = ( + state: SortFilterStateValue, + l: Torrent[], + categoryState: CategoryState +): Torrent[] => { + const { column, asc, search, category: selectedCategory } = state; + + if (search) { + const fuzzyResults = fuzzysort.go(search, l, { key: 'name' }); + return fuzzyResults.map(({ obj }) => obj); + } + + const filteredList = + selectedCategory === '__all__' + ? l + : l.filter(({ category }) => getNormalizedCategory(category) === selectedCategory); + + return sortTorrent(filteredList, column, asc); +}; + +export const buildCategory = ({ + name, + savePath = '', + hashList = [], + __internal = false, +}: Partial> & { name: string }): Category => ({ + name, + savePath, + hashList, + __internal, +}); + +export const getCategoryName = (c: Category) => (c.__internal ? c.__internal : c.name); + +export const getInitialCategories = (intl: IntlShape, serverCategories: Record) => { + return Object.entries(serverCategories).reduce( + (acc, [categoryName, category]) => { + acc[categoryName] = buildCategory(category); + + return acc; + }, + { + __all__: buildCategory({ + __internal: '__all__', + name: intl.formatMessage({ defaultMessage: 'All categories' }), + }), + __none__: buildCategory({ + __internal: '__none__', + name: intl.formatMessage({ defaultMessage: 'Uncategorized' }), + }), + } as Record + ); +}; diff --git a/src/components/torrents/columns.tsx b/src/components/torrents/columns.tsx index 552a92a..de8dac8 100644 --- a/src/components/torrents/columns.tsx +++ b/src/components/torrents/columns.tsx @@ -9,6 +9,7 @@ export const tableColumns: TableColumn[] = [ width: 460, }, { label: , dataKey: 'added_on', width: 160 }, + { label: , dataKey: 'eta', width: 120 }, { label: , dataKey: 'ratio', diff --git a/src/components/torrents/context-menu.tsx b/src/components/torrents/context-menu.tsx index 9fa7e2c..b629b73 100644 --- a/src/components/torrents/context-menu.tsx +++ b/src/components/torrents/context-menu.tsx @@ -1,6 +1,6 @@ import produce from 'immer'; import { useIntl } from 'react-intl'; -import { FC, memo, MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, memo, MouseEventHandler, useCallback, useEffect, useState } from 'react'; import { mStyles } from '../common'; import { Popover, List, ListItem, ListItemText, ListItemIcon } from '../material-ui-core'; import { getElementAttr, getVisibilityCompatibleKeys, tryCatchSync } from '../../utils'; diff --git a/src/components/torrents/list.tsx b/src/components/torrents/list.tsx index f644bd5..ddbe603 100644 --- a/src/components/torrents/list.tsx +++ b/src/components/torrents/list.tsx @@ -7,7 +7,7 @@ import { getTableColumn, getColumnWidth, tableColumns } from './columns'; import { getTorrentOrElse } from './utils'; import { BodyCell, HeaderCell } from './cell'; -export const HEADER_CELL_HEIGHT = 44; +export const HEADER_CELL_HEIGHT = 48; export const ROW_CELL_HEIGHT = 32; const useStyles = mStyles(({ palette, typography }) => ({ diff --git a/src/components/torrents/renderers.tsx b/src/components/torrents/renderers.tsx index d767eab..74ef81e 100644 --- a/src/components/torrents/renderers.tsx +++ b/src/components/torrents/renderers.tsx @@ -3,7 +3,7 @@ import { TableHeaderProps } from 'react-virtualized'; import { FormattedMessage, FormattedRelativeTime, IntlShape } from 'react-intl'; import { DayJs } from '../common'; import { IconButton, LinearProgress } from '../material-ui-core'; -import { AllInclusiveIcon, DoneIcon, MoreVertIcon } from '../material-ui-icons'; +import { AllInclusiveIcon, DoneIcon, HelpIcon, HelpOutlineIcon, MoreVertIcon } from '../material-ui-icons'; import { getTorrentStateIcon, getTorrentStateString } from './utils'; import { formatPercentage, humanFileSize } from '../../utils'; import { TorrentState } from '../../api'; @@ -22,11 +22,17 @@ const DivBox: FC = ({ }; export const dateCellRenderer = (value: number) => DayJs(value * 1000).format('YYYY-MM-DD HH:mm A'); -export const relativeDateCellRenderer = (value: number) => +export const remainingTimeCellRenderer = (value: number) => + value < 8_640_000 ? ( + + ) : ( + + ); +export const relativeTimeCellRenderer = (value: number) => value > 0 ? ( ) : ( - + ); export const ratioCellRenderer = (value: number) => {value.toFixed(2)}; export const statusCellRenderer = (value: TorrentState) => ( @@ -141,9 +147,11 @@ export const cellRenderer = ( ); case 'added_on': return dateCellRenderer(value as number); + case 'eta': + return remainingTimeCellRenderer(value as number); case 'time_active': case 'last_activity': - return relativeDateCellRenderer(value as number); + return relativeTimeCellRenderer(value as number); case 'upspeed': case 'dlspeed': return speedCellRenderer(value as number); diff --git a/src/components/torrents/utils.tsx b/src/components/torrents/utils.tsx index 5a42d84..deb7595 100644 --- a/src/components/torrents/utils.tsx +++ b/src/components/torrents/utils.tsx @@ -1,7 +1,6 @@ import { ReactNode } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Torrent, TorrentState } from '../../api'; -import { TorrentCollection } from '../../types'; +import { Torrent, TorrentCollection, TorrentState } from '../../api'; import { copyToClipboard, getElementAttr } from '../../utils'; import { ArrowDownwardIcon, diff --git a/src/components/utils/events.ts b/src/components/utils/events.ts new file mode 100644 index 0000000..430fb72 --- /dev/null +++ b/src/components/utils/events.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import { isElement } from '../../utils'; + +export const useClickOutsideElement = (callback: (e: Event) => void, el: Element | null | undefined) => { + useEffect(() => { + function handleEvent(event: Event) { + if (typeof callback === 'function' && el && isElement(event.target) && !el.contains(event.target)) { + callback(event); + } + } + + document.addEventListener('mouseup', handleEvent); + document.addEventListener('touchend', handleEvent); + + return () => { + document.removeEventListener('mouseup', handleEvent); + document.removeEventListener('touchend', handleEvent); + }; + }); +}; + +export const useDocumentEvents = ( + callback: (e: DocumentEventMap[T]) => void, + events: T[] +) => { + useEffect(() => { + function handleEvent(event: DocumentEventMap[T]) { + if (typeof callback === 'function') { + callback(event); + } + } + + events.forEach(eventName => { + document.addEventListener(eventName, handleEvent); + }); + + return () => { + events.forEach(eventName => { + document.removeEventListener(eventName, handleEvent); + }); + }; + }); +}; diff --git a/src/constant.ts b/src/constant.ts index 5d9acf2..5b9dc86 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,4 +1,4 @@ -export const WEB_UI_VERSION = '0.1.0'; +export const WEB_UI_VERSION = require('../package.json').version as string; export const PUBLIC_URL = process.env.PUBLIC_URL || ''; export const DEV_SERVER_BASE_URL = process.env.QBT_DEV_SERVER_BASE_URL; export const API_BASE_URL = DEV_SERVER_BASE_URL ?? process.env.QBT_API_BASE_URL ?? ''; diff --git a/src/lang/en.json b/src/lang/en.json index 4a6f913..51f4787 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -8,12 +8,21 @@ "1M6M38": { "defaultMessage": "Set a new location for {itemCount}:" }, + "1mQAJl": { + "defaultMessage": "Edit category" + }, "1w0KAb": { "defaultMessage": "Add new torrents" }, "2/2yg+": { "defaultMessage": "Add" }, + "2GURFS": { + "defaultMessage": "Pause torrents" + }, + "2goucs": { + "defaultMessage": "Delete category" + }, "38Uuxq": { "defaultMessage": "Set a new limit ratio for {itemCount}:" }, @@ -38,6 +47,9 @@ "4xq39n": { "defaultMessage": "Update share limit ratio" }, + "5IioVR": { + "defaultMessage": "Fail to create '{category}'" + }, "5jeq8P": { "defaultMessage": "Unknown" }, @@ -47,6 +59,9 @@ "7hccGY": { "defaultMessage": "One URL/link per line, press Enter to add extra" }, + "7uWmG1": { + "defaultMessage": "Delete torrents" + }, "8AXy/H": { "defaultMessage": "Downloading [F]" }, @@ -56,9 +71,15 @@ "95stPq": { "defaultMessage": "Completed" }, + "96hNL1": { + "defaultMessage": "{category} category deleted" + }, "9CV2vm": { "defaultMessage": "URLs" }, + "9g2jKz": { + "defaultMessage": "All categories" + }, "9naK/J": { "defaultMessage": "Toggled sequential download {itemCount, plural, one {# item} other {# items} }" }, @@ -122,6 +143,9 @@ "HAlOn1": { "defaultMessage": "Name" }, + "HP5Uui": { + "defaultMessage": "Apply to selection" + }, "HeqCOM": { "defaultMessage": "{ratio} ratio or {minutes} minutes" }, @@ -161,6 +185,9 @@ "L3+RNP": { "defaultMessage": "Seeding [F]" }, + "L6waN2": { + "defaultMessage": "Path to store torrents" + }, "LaM7n1": { "defaultMessage": "download" }, @@ -182,6 +209,9 @@ "Ma5clI": { "defaultMessage": "Upload files from disk" }, + "N2IrpM": { + "defaultMessage": "Confirm" + }, "NXI/XL": { "defaultMessage": "Auto" }, @@ -191,6 +221,9 @@ "OlwlCi": { "defaultMessage": "Downloaded" }, + "PVl3Rq": { + "defaultMessage": "ETA" + }, "PfW4AI": { "defaultMessage": "No limit" }, @@ -203,21 +236,36 @@ "R0VtDp": { "defaultMessage": "Auto management {state} on {itemCount, plural, one {# item} other {# items} }" }, + "SDuF98": { + "defaultMessage": "Fail to update '{category}'" + }, "UIc25b": { "defaultMessage": "Management mode" }, "UWFphS": { "defaultMessage": "Copied {itemCount, plural, one {# hash} other {# hashes} } to clipboard" }, + "UkFz0c": { + "defaultMessage": "Failure to delete '{category}'" + }, "V2T7Eg": { "defaultMessage": "Limit download rate" }, + "V4DdBL": { + "defaultMessage": "Apply to selection ({count})" + }, "VG+Z9f": { "defaultMessage": "Share Limit" }, + "VKb1MS": { + "defaultMessage": "Categories" + }, "VNNgop": { "defaultMessage": "Seeding" }, + "VzzYJk": { + "defaultMessage": "Create" + }, "W+1MOm": { "defaultMessage": "Method" }, @@ -230,6 +278,9 @@ "YeKWbP": { "defaultMessage": "Authentication" }, + "ZROXxK": { + "defaultMessage": "Create category" + }, "ZjWGgI": { "defaultMessage": "Move to Bottom of Queue" }, @@ -275,9 +326,15 @@ "hceE/7": { "defaultMessage": "Force Resume" }, + "iO050q": { + "defaultMessage": "Uncategorized" + }, "iXNbPf": { "defaultMessage": "Rename" }, + "iZFhin": { + "defaultMessage": "Are you sure you wanna delete {item}?" + }, "j10KuP": { "defaultMessage": "Recheck [F]" }, @@ -293,6 +350,9 @@ "k8l06l": { "defaultMessage": "Stalled" }, + "l8NdEW": { + "defaultMessage": "{category} category created" + }, "lTleCS": { "defaultMessage": "Checking" }, @@ -338,6 +398,9 @@ "rtGl5i": { "defaultMessage": "Delete confirmation" }, + "rw0/aE": { + "defaultMessage": "{category} category updated" + }, "rzuqCK": { "defaultMessage": "Down Limit" }, @@ -362,12 +425,21 @@ "uUCYxK": { "defaultMessage": "Download First/Last Piece First" }, + "ubROvH": { + "defaultMessage": "Resume torrents" + }, "v3MWTZ": { "defaultMessage": "online" }, "v97Te1": { "defaultMessage": "Checking resume data" }, + "vEYtiq": { + "defaultMessage": "Category Name" + }, + "vgeCVD": { + "defaultMessage": "Modify category" + }, "vzu1AA": { "defaultMessage": "Meta Download" }, diff --git a/src/types.ts b/src/types.ts index 9cfbaf8..b9dff7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,8 @@ -import { Torrent } from './api'; - export type Lazy = () => T; export type LazyReason = (reason: R) => T; export type Fallback = Lazy | T; export type FallbackReason = LazyReason | T; -export type TorrentCollection = Record; - export type NoOp = () => void; // Reference https://github.com/Microsoft/TypeScript/issues/13298#issuecomment-707364842 diff --git a/src/utils/domHelpers.ts b/src/utils/dom-helpers.ts similarity index 60% rename from src/utils/domHelpers.ts rename to src/utils/dom-helpers.ts index c4a7baa..a67f8e3 100644 --- a/src/utils/domHelpers.ts +++ b/src/utils/dom-helpers.ts @@ -3,3 +3,7 @@ import { tryCatchSync } from './tryCatch'; export const getElementAttr = (attr: string, fallback: Fallback, element?: Element): T => tryCatchSync(() => element!.getAttribute(attr), fallback) as T; + +export const isElement = (i: any): i is Element => + i && + (i.nodeType === Node.ELEMENT_NODE || i.nodeType === Node.TEXT_NODE || i.nodeType === Node.DOCUMENT_NODE); diff --git a/src/utils/index.ts b/src/utils/index.ts index 9e9ae05..70b8dba 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,6 @@ export * from './browsers'; export * from './colors'; -export * from './domHelpers'; +export * from './dom-helpers'; export * from './fileSize'; export * from './formData'; export * from './functions';