mirror of
https://github.com/zoriya/flood.git
synced 2026-06-07 04:15:06 +00:00
feature: suggest tag-specific destination to textbox on tag selection
This commit is contained in:
+131
-122
@@ -1,6 +1,7 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {FC, ReactNode, useEffect, useRef, useState} from 'react';
|
||||
import {forwardRef, MutableRefObject, ReactNode, useEffect, useRef, useState} from 'react';
|
||||
import {useEnsuredForwardedRef} from 'react-use';
|
||||
|
||||
import {Checkbox, ContextMenu, FormElementAddon, FormRow, FormRowGroup, Portal, Textbox} from '../../../ui';
|
||||
import FilesystemBrowser from '../filesystem/FilesystemBrowser';
|
||||
@@ -17,129 +18,137 @@ interface FilesystemBrowserTextboxProps {
|
||||
onChange?: (destination: string) => void;
|
||||
}
|
||||
|
||||
const FilesystemBrowserTextbox: FC<FilesystemBrowserTextboxProps> = ({
|
||||
id,
|
||||
label,
|
||||
selectable,
|
||||
suggested,
|
||||
showBasePathToggle,
|
||||
showCompletedToggle,
|
||||
onChange,
|
||||
}: FilesystemBrowserTextboxProps) => {
|
||||
const [destination, setDestination] = useState<string>(
|
||||
suggested ??
|
||||
SettingStore.floodSettings.torrentDestinations?.[''] ??
|
||||
SettingStore.clientSettings?.directoryDefault ??
|
||||
'',
|
||||
);
|
||||
const [isDirectoryListOpen, setIsDirectoryListOpen] = useState<boolean>(false);
|
||||
|
||||
const formRowRef = useRef<HTMLDivElement>(null);
|
||||
const textboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
const closeDirectoryList = (): void => {
|
||||
setIsDirectoryListOpen(false);
|
||||
};
|
||||
|
||||
const handleDocumentClick = (e: Event): void => {
|
||||
if (!formRowRef.current?.contains((e.target as unknown) as Node)) {
|
||||
closeDirectoryList();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
window.addEventListener('resize', closeDirectoryList);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
window.removeEventListener('resize', closeDirectoryList);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggles: React.ReactNodeArray = [];
|
||||
if (showBasePathToggle) {
|
||||
toggles.push(
|
||||
<Checkbox grow={false} id="isBasePath" key="isBasePath">
|
||||
<FormattedMessage id="torrents.destination.base_path" />
|
||||
</Checkbox>,
|
||||
const FilesystemBrowserTextbox = forwardRef<HTMLInputElement, FilesystemBrowserTextboxProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
selectable,
|
||||
suggested,
|
||||
showBasePathToggle,
|
||||
showCompletedToggle,
|
||||
onChange,
|
||||
}: FilesystemBrowserTextboxProps,
|
||||
ref,
|
||||
) => {
|
||||
const [destination, setDestination] = useState<string>(
|
||||
suggested ??
|
||||
SettingStore.floodSettings.torrentDestinations?.[''] ??
|
||||
SettingStore.clientSettings?.directoryDefault ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (showCompletedToggle) {
|
||||
toggles.push(
|
||||
<Checkbox grow={false} id="isCompleted" key="isCompleted">
|
||||
<FormattedMessage id="torrents.destination.completed" />
|
||||
</Checkbox>,
|
||||
const [isDirectoryListOpen, setIsDirectoryListOpen] = useState<boolean>(false);
|
||||
|
||||
const formRowRef = useRef<HTMLDivElement>(null);
|
||||
const textboxRef = useEnsuredForwardedRef(ref as MutableRefObject<HTMLInputElement>);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
const closeDirectoryList = (): void => {
|
||||
setIsDirectoryListOpen(false);
|
||||
};
|
||||
|
||||
const handleDocumentClick = (e: Event): void => {
|
||||
if (!formRowRef.current?.contains((e.target as unknown) as Node)) {
|
||||
closeDirectoryList();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
window.addEventListener('resize', closeDirectoryList);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
window.removeEventListener('resize', closeDirectoryList);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggles: React.ReactNodeArray = [];
|
||||
if (showBasePathToggle) {
|
||||
toggles.push(
|
||||
<Checkbox grow={false} id="isBasePath" key="isBasePath">
|
||||
<FormattedMessage id="torrents.destination.base_path" />
|
||||
</Checkbox>,
|
||||
);
|
||||
}
|
||||
if (showCompletedToggle) {
|
||||
toggles.push(
|
||||
<Checkbox grow={false} id="isCompleted" key="isCompleted">
|
||||
<FormattedMessage id="torrents.destination.completed" />
|
||||
</Checkbox>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormRowGroup ref={formRowRef}>
|
||||
<FormRow>
|
||||
<Textbox
|
||||
autoComplete={isDirectoryListOpen ? 'off' : undefined}
|
||||
addonPlacement="after"
|
||||
defaultValue={destination}
|
||||
id={id}
|
||||
label={label}
|
||||
onChange={debounce(
|
||||
() => {
|
||||
if (textboxRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDestination = textboxRef.current.value;
|
||||
|
||||
if (onChange) {
|
||||
onChange(newDestination);
|
||||
}
|
||||
|
||||
setDestination(newDestination);
|
||||
},
|
||||
100,
|
||||
{leading: true},
|
||||
)}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.add.destination.placeholder',
|
||||
})}
|
||||
ref={textboxRef}>
|
||||
<FormElementAddon
|
||||
onClick={() => {
|
||||
if (textboxRef.current != null) {
|
||||
setDestination(textboxRef.current.value);
|
||||
}
|
||||
setIsDirectoryListOpen(!isDirectoryListOpen);
|
||||
}}>
|
||||
<Search />
|
||||
</FormElementAddon>
|
||||
<Portal>
|
||||
<ContextMenu
|
||||
isIn={isDirectoryListOpen}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
overlayProps={{isInteractive: false}}
|
||||
padding={false}
|
||||
triggerRef={textboxRef}>
|
||||
<FilesystemBrowser
|
||||
directory={destination}
|
||||
intl={intl}
|
||||
selectable={selectable}
|
||||
onItemSelection={(newDestination: string, isDirectory = true) => {
|
||||
if (textboxRef.current != null) {
|
||||
textboxRef.current.value = newDestination;
|
||||
}
|
||||
|
||||
setDestination(newDestination);
|
||||
setIsDirectoryListOpen(isDirectory);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
</Textbox>
|
||||
</FormRow>
|
||||
{toggles.length > 0 ? <FormRow>{toggles}</FormRow> : null}
|
||||
</FormRowGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormRowGroup ref={formRowRef}>
|
||||
<FormRow>
|
||||
<Textbox
|
||||
autoComplete={isDirectoryListOpen ? 'off' : undefined}
|
||||
addonPlacement="after"
|
||||
defaultValue={destination}
|
||||
id={id}
|
||||
label={label}
|
||||
onChange={debounce(
|
||||
() => {
|
||||
if (textboxRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDestination = textboxRef.current.value;
|
||||
|
||||
if (onChange) {
|
||||
onChange(newDestination);
|
||||
}
|
||||
|
||||
setDestination(newDestination);
|
||||
},
|
||||
100,
|
||||
{leading: true},
|
||||
)}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.add.destination.placeholder',
|
||||
})}
|
||||
ref={textboxRef}>
|
||||
<FormElementAddon
|
||||
onClick={() => {
|
||||
setIsDirectoryListOpen(!isDirectoryListOpen);
|
||||
}}>
|
||||
<Search />
|
||||
</FormElementAddon>
|
||||
<Portal>
|
||||
<ContextMenu
|
||||
isIn={isDirectoryListOpen}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
overlayProps={{isInteractive: false}}
|
||||
padding={false}
|
||||
triggerRef={textboxRef}>
|
||||
<FilesystemBrowser
|
||||
directory={destination}
|
||||
intl={intl}
|
||||
selectable={selectable}
|
||||
onItemSelection={(newDestination: string, isDirectory = true) => {
|
||||
if (textboxRef.current != null) {
|
||||
textboxRef.current.value = newDestination;
|
||||
}
|
||||
|
||||
setDestination(newDestination);
|
||||
setIsDirectoryListOpen(isDirectory);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
</Textbox>
|
||||
</FormRow>
|
||||
{toggles.length > 0 ? <FormRow>{toggles}</FormRow> : null}
|
||||
</FormRowGroup>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
FilesystemBrowserTextbox.defaultProps = {
|
||||
label: undefined,
|
||||
|
||||
@@ -15,9 +15,10 @@ interface TagSelectProps {
|
||||
label?: ReactNode;
|
||||
defaultValue?: TorrentProperties['tags'];
|
||||
placeholder?: string;
|
||||
onTagSelected?: (tags: TorrentProperties['tags']) => void;
|
||||
}
|
||||
|
||||
const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label}: TagSelectProps) => {
|
||||
const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label, onTagSelected}: TagSelectProps) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [selectedTags, setSelectedTags] = useState<Array<string>>(defaultValue ?? []);
|
||||
const formRowRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,7 +52,10 @@ const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label}: T
|
||||
if (textboxRef.current != null) {
|
||||
textboxRef.current.value = selectedTags.join();
|
||||
}
|
||||
}, [selectedTags]);
|
||||
if (onTagSelected) {
|
||||
onTagSelected(selectedTags);
|
||||
}
|
||||
}, [selectedTags, onTagSelected]);
|
||||
|
||||
return (
|
||||
<FormRowItem ref={formRowRef}>
|
||||
@@ -62,6 +66,24 @@ const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label}: T
|
||||
id={id || 'tags'}
|
||||
addonPlacement="after"
|
||||
defaultValue={defaultValue}
|
||||
onChange={() => {
|
||||
if (textboxRef.current != null) {
|
||||
let selectedTagsArray = textboxRef.current.value
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
if (textboxRef.current.value.trimEnd().endsWith(',')) {
|
||||
// Ensures that the trailing ',' does not get removed automatically.
|
||||
selectedTagsArray.push('');
|
||||
|
||||
// Deduplicate
|
||||
selectedTagsArray = [...new Set(selectedTagsArray)];
|
||||
}
|
||||
|
||||
setSelectedTags(selectedTagsArray);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
ref={textboxRef}>
|
||||
<FormElementAddon
|
||||
@@ -82,30 +104,33 @@ const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label}: T
|
||||
overlayProps={{isInteractive: false}}
|
||||
ref={menuRef}
|
||||
triggerRef={textboxRef}>
|
||||
{Object.keys(TorrentFilterStore.taxonomy.tagCounts).reduce((accumulator: ReactNodeArray, tag) => {
|
||||
if (tag === '') {
|
||||
return accumulator;
|
||||
}
|
||||
{[...new Set([...Object.keys(TorrentFilterStore.taxonomy.tagCounts), ...selectedTags])].reduce(
|
||||
(accumulator: ReactNodeArray, tag) => {
|
||||
if (tag === '') {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
accumulator.push(
|
||||
<SelectItem
|
||||
id={tag}
|
||||
key={tag}
|
||||
isSelected={selectedTags.includes(tag)}
|
||||
onClick={() => {
|
||||
if (tag === 'untagged') {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(tag)) {
|
||||
setSelectedTags(selectedTags.filter((key) => key !== tag));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag]);
|
||||
}
|
||||
}}>
|
||||
{tag === 'untagged' ? <FormattedMessage id="filter.untagged" /> : tag}
|
||||
</SelectItem>,
|
||||
);
|
||||
return accumulator;
|
||||
}, [])}
|
||||
accumulator.push(
|
||||
<SelectItem
|
||||
id={tag}
|
||||
key={tag}
|
||||
isSelected={selectedTags.includes(tag)}
|
||||
onClick={() => {
|
||||
if (tag === 'untagged') {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(tag)) {
|
||||
setSelectedTags(selectedTags.filter((key) => key !== tag && key !== ''));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags.filter((key) => key !== ''), tag]);
|
||||
}
|
||||
}}>
|
||||
{tag === 'untagged' ? <FormattedMessage id="filter.untagged" /> : tag}
|
||||
</SelectItem>,
|
||||
);
|
||||
return accumulator;
|
||||
},
|
||||
[],
|
||||
)}
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
</Textbox>
|
||||
@@ -119,6 +144,7 @@ TagSelect.defaultProps = {
|
||||
label: undefined,
|
||||
defaultValue: undefined,
|
||||
placeholder: undefined,
|
||||
onTagSelected: undefined,
|
||||
};
|
||||
|
||||
export default TagSelect;
|
||||
|
||||
@@ -6,6 +6,7 @@ import FileDropzone from '../../general/form-elements/FileDropzone';
|
||||
import FilesystemBrowserTextbox from '../../general/form-elements/FilesystemBrowserTextbox';
|
||||
import {Form, FormRow} from '../../../ui';
|
||||
import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences';
|
||||
import SettingStore from '../../../stores/SettingStore';
|
||||
import TagSelect from '../../general/form-elements/TagSelect';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
import UIStore from '../../../stores/UIStore';
|
||||
@@ -23,9 +24,11 @@ interface AddTorrentsByFileFormData {
|
||||
const AddTorrentsByFile: FC = () => {
|
||||
const filesRef = useRef<ProcessedFiles>([]);
|
||||
const formRef = useRef<Form>(null);
|
||||
const intl = useIntl();
|
||||
const textboxRef = useRef<HTMLInputElement>(null);
|
||||
const [isAddingTorrents, setIsAddingTorrents] = useState<boolean>(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Form className="inverse" ref={formRef}>
|
||||
<FormRow>
|
||||
@@ -35,23 +38,33 @@ const AddTorrentsByFile: FC = () => {
|
||||
}}
|
||||
/>
|
||||
</FormRow>
|
||||
<FilesystemBrowserTextbox
|
||||
id="destination"
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.destination.label',
|
||||
})}
|
||||
selectable="directories"
|
||||
showBasePathToggle
|
||||
showCompletedToggle
|
||||
/>
|
||||
<FormRow>
|
||||
<TagSelect
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.tags',
|
||||
})}
|
||||
id="tags"
|
||||
onTagSelected={(tags) => {
|
||||
if (textboxRef.current != null) {
|
||||
const suggestedPath = SettingStore.floodSettings.torrentDestinations?.[tags[0]];
|
||||
if (typeof suggestedPath === 'string' && textboxRef.current != null) {
|
||||
textboxRef.current.value = suggestedPath;
|
||||
textboxRef.current.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormRow>
|
||||
<FilesystemBrowserTextbox
|
||||
id="destination"
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.destination.label',
|
||||
})}
|
||||
ref={textboxRef}
|
||||
selectable="directories"
|
||||
showBasePathToggle
|
||||
showCompletedToggle
|
||||
/>
|
||||
<AddTorrentsActions
|
||||
onAddTorrentsClick={() => {
|
||||
if (formRef.current == null) {
|
||||
@@ -73,7 +86,7 @@ const AddTorrentsByFile: FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsArray = tags != null ? tags.split(',') : undefined;
|
||||
const tagsArray = tags != null ? tags.split(',').filter((tag) => tag.length > 0) : undefined;
|
||||
|
||||
TorrentActions.addTorrentsByFiles({
|
||||
files: filesData as [string, ...string[]],
|
||||
|
||||
@@ -5,6 +5,7 @@ import AddTorrentsActions from './AddTorrentsActions';
|
||||
import FilesystemBrowserTextbox from '../../general/form-elements/FilesystemBrowserTextbox';
|
||||
import {Form, FormRow} from '../../../ui';
|
||||
import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences';
|
||||
import SettingStore from '../../../stores/SettingStore';
|
||||
import TagSelect from '../../general/form-elements/TagSelect';
|
||||
import TextboxRepeater, {getTextArray} from '../../general/form-elements/TextboxRepeater';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
@@ -24,9 +25,11 @@ type AddTorrentsByURLFormData = {
|
||||
|
||||
const AddTorrentsByURL: FC = () => {
|
||||
const formRef = useRef<Form>(null);
|
||||
const intl = useIntl();
|
||||
const textboxRef = useRef<HTMLInputElement>(null);
|
||||
const [isAddingTorrents, setIsAddingTorrents] = useState<boolean>(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Form className="inverse" ref={formRef}>
|
||||
<TextboxRepeater
|
||||
@@ -50,23 +53,33 @@ const AddTorrentsByURL: FC = () => {
|
||||
id: 'torrents.add.cookies.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
<FilesystemBrowserTextbox
|
||||
id="destination"
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.destination.label',
|
||||
})}
|
||||
selectable="directories"
|
||||
showBasePathToggle
|
||||
showCompletedToggle
|
||||
/>
|
||||
<FormRow>
|
||||
<TagSelect
|
||||
id="tags"
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.tags',
|
||||
})}
|
||||
onTagSelected={(tags) => {
|
||||
if (textboxRef.current != null) {
|
||||
const suggestedPath = SettingStore.floodSettings.torrentDestinations?.[tags[0]];
|
||||
if (typeof suggestedPath === 'string' && textboxRef.current != null) {
|
||||
textboxRef.current.value = suggestedPath;
|
||||
textboxRef.current.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormRow>
|
||||
<FilesystemBrowserTextbox
|
||||
id="destination"
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.destination.label',
|
||||
})}
|
||||
ref={textboxRef}
|
||||
selectable="directories"
|
||||
showBasePathToggle
|
||||
showCompletedToggle
|
||||
/>
|
||||
<AddTorrentsActions
|
||||
onAddTorrentsClick={() => {
|
||||
if (formRef.current == null) {
|
||||
@@ -93,7 +106,7 @@ const AddTorrentsByURL: FC = () => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const tags = formData.tags != null ? formData.tags.split(',') : undefined;
|
||||
const tags = formData.tags != null ? formData.tags.split(',').filter((tag) => tag.length > 0) : undefined;
|
||||
|
||||
TorrentActions.addTorrentsByUrls({
|
||||
urls: urls as [string, ...string[]],
|
||||
|
||||
Reference in New Issue
Block a user