Filters Redesign (#856)
New filters system adding intelisense Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { Languages } from '@utils/constants/languages';
|
||||
import { load } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
import { NovelStatus, Plugin, PluginItem } from './types';
|
||||
import { FilterInputs } from './types/filterTypes';
|
||||
import { FilterTypes } from './types/filterTypes';
|
||||
import { parseMadaraDate } from './helpers/parseDate';
|
||||
import { isUrlAbsolute } from '@utils/isAbsoluteUrl';
|
||||
import { fetchApi, fetchFile } from '@utils/fetch/fetch';
|
||||
@@ -21,7 +21,7 @@ const packages: Record<string, any> = {
|
||||
'@libs/fetch': { fetchApi, fetchFile },
|
||||
'@libs/parseMadaraDate': { parseMadaraDate },
|
||||
'@libs/isAbsoluteUrl': { isUrlAbsolute },
|
||||
'@libs/filterInputs': { FilterInputs },
|
||||
'@libs/filterInputs': { FilterTypes },
|
||||
'@libs/showToast': { showToast },
|
||||
'@libs/defaultCover': { defaultCover },
|
||||
};
|
||||
@@ -69,7 +69,7 @@ let plugins: Record<string, Plugin> = {};
|
||||
// get existing plugin in device
|
||||
const setupPlugin = async (path: string) => {
|
||||
const rawCode = await RNFS.readFile(path, 'utf8');
|
||||
const plugin = await initPlugin(rawCode, path);
|
||||
const plugin = initPlugin(rawCode, path);
|
||||
if (!plugin) {
|
||||
showToast(`Invalid script in: ${path}`);
|
||||
}
|
||||
@@ -78,7 +78,9 @@ const setupPlugin = async (path: string) => {
|
||||
|
||||
const installPlugin = async (url: string): Promise<Plugin | undefined> => {
|
||||
try {
|
||||
return await fetch(url)
|
||||
return await fetch(url, {
|
||||
headers: { 'pragma': 'no-cache', 'cache-control': 'no-cache' },
|
||||
})
|
||||
.then(res => res.text())
|
||||
.then(async rawCode => {
|
||||
const plugin = initPlugin(rawCode);
|
||||
@@ -143,9 +145,9 @@ const collectPlugins = async () => {
|
||||
|
||||
const fetchPlugins = async () => {
|
||||
// plugins host
|
||||
const githubUsername = 'LNReader';
|
||||
const githubRepository = 'lnreader-sources';
|
||||
const githubBranch = 'plugins';
|
||||
const githubUsername = 'skillgg';
|
||||
const githubRepository = 'lnplugins';
|
||||
const githubBranch = 'filters_redesign';
|
||||
|
||||
const availablePlugins: Record<Languages, Array<PluginItem>> = await fetch(
|
||||
`https://raw.githubusercontent.com/${githubUsername}/${githubRepository}/${githubBranch}/.dist/${githubUsername}/plugins.min.json`,
|
||||
|
||||
@@ -1,19 +1,121 @@
|
||||
interface FilterValue {
|
||||
label: string;
|
||||
export interface FilterOption {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
}
|
||||
export enum FilterTypes {
|
||||
TextInput = 'Text',
|
||||
Picker = 'Picker',
|
||||
CheckboxGroup = 'Checkbox',
|
||||
Switch = 'Switch',
|
||||
ExcludableCheckboxGroup = 'XCheckbox',
|
||||
}
|
||||
|
||||
type SwitchFilter = {
|
||||
type: FilterTypes.Switch;
|
||||
/** Default value */
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
type TextFilter = {
|
||||
type: FilterTypes.TextInput;
|
||||
/** Default value */
|
||||
value: string;
|
||||
}
|
||||
};
|
||||
|
||||
export enum FilterInputs {
|
||||
TextInput,
|
||||
Picker,
|
||||
Checkbox,
|
||||
}
|
||||
type CheckboxFilter = {
|
||||
type: FilterTypes.CheckboxGroup;
|
||||
options: readonly FilterOption[];
|
||||
/** Default value */
|
||||
value: string[];
|
||||
};
|
||||
type PickerFilter = {
|
||||
type: FilterTypes.Picker;
|
||||
options: readonly FilterOption[];
|
||||
/** Default value */
|
||||
value: string;
|
||||
};
|
||||
|
||||
export interface SourceFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
values: FilterValue[];
|
||||
inputType: FilterInputs;
|
||||
}
|
||||
type ExcludableCheckboxFilter = {
|
||||
type: FilterTypes.ExcludableCheckboxGroup;
|
||||
options: readonly FilterOption[];
|
||||
value: { include?: string[]; exclude?: string[] };
|
||||
};
|
||||
|
||||
export type SelectedFilter = Record<string, string | string[]>;
|
||||
export type Filters = Record<
|
||||
string,
|
||||
| { label: string } & (
|
||||
| PickerFilter
|
||||
| CheckboxFilter
|
||||
| TextFilter
|
||||
| SwitchFilter
|
||||
| ExcludableCheckboxFilter
|
||||
)
|
||||
>;
|
||||
|
||||
export type FilterToValues<
|
||||
T extends Record<string, { type: FilterTypes }> | undefined,
|
||||
> = T extends undefined
|
||||
? undefined
|
||||
: {
|
||||
[K in keyof T]: Omit<
|
||||
{ type: NonNullable<T>[K]['type'] } & Filters[string],
|
||||
'label' | 'options'
|
||||
>;
|
||||
};
|
||||
|
||||
export type ValueOfFilter<T extends FilterTypes> =
|
||||
T extends FilterTypes.CheckboxGroup
|
||||
? CheckboxFilter['value']
|
||||
: T extends FilterTypes.Picker
|
||||
? PickerFilter['value']
|
||||
: T extends FilterTypes.Switch
|
||||
? SwitchFilter['value']
|
||||
: T extends FilterTypes.TextInput
|
||||
? TextFilter['value']
|
||||
: T extends FilterTypes.ExcludableCheckboxGroup
|
||||
? ExcludableCheckboxFilter['value']
|
||||
: never;
|
||||
|
||||
export const isPickerValue = (
|
||||
q: FilterToValues<Filters>[string],
|
||||
): q is FilterToValues<{
|
||||
[key: string]: { label: string } & PickerFilter;
|
||||
}>[string] => {
|
||||
return q.type === FilterTypes.Picker && typeof q.value === 'string';
|
||||
};
|
||||
|
||||
export const isCheckboxValue = (
|
||||
q: FilterToValues<Filters>[string],
|
||||
): q is FilterToValues<{
|
||||
[key: string]: { label: string } & CheckboxFilter;
|
||||
}>[string] => {
|
||||
return q.type === FilterTypes.CheckboxGroup && Array.isArray(q.value);
|
||||
};
|
||||
|
||||
export const isSwitchValue = (
|
||||
q: FilterToValues<Filters>[string],
|
||||
): q is FilterToValues<{
|
||||
[key: string]: { label: string } & SwitchFilter;
|
||||
}>[string] => {
|
||||
return q.type === FilterTypes.Switch && typeof q.value === 'boolean';
|
||||
};
|
||||
|
||||
export const isTextValue = (
|
||||
q: FilterToValues<Filters>[string],
|
||||
): q is FilterToValues<{
|
||||
[key: string]: { label: string } & TextFilter;
|
||||
}>[string] => {
|
||||
return q.type === FilterTypes.TextInput && typeof q.value === 'string';
|
||||
};
|
||||
|
||||
export const isXCheckboxValue = (
|
||||
q: FilterToValues<Filters>[string],
|
||||
): q is FilterToValues<{
|
||||
[key: string]: { label: string } & ExcludableCheckboxFilter;
|
||||
}>[string] => {
|
||||
return (
|
||||
q.type === FilterTypes.ExcludableCheckboxGroup &&
|
||||
typeof q.value === 'object' &&
|
||||
!Array.isArray(q.value)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SelectedFilter, SourceFilter } from './filterTypes';
|
||||
import { FilterToValues, Filters } from './filterTypes';
|
||||
import { Languages } from '@utils/constants/languages';
|
||||
|
||||
export interface NovelItem {
|
||||
@@ -35,9 +35,9 @@ export interface SourceNovel {
|
||||
chapters?: ChapterItem[];
|
||||
}
|
||||
|
||||
export interface SourceOptions {
|
||||
export interface PopularNovelsOptions<Q extends Filters> {
|
||||
showLatestNovels?: boolean;
|
||||
filters?: SelectedFilter;
|
||||
filters?: FilterToValues<Q>;
|
||||
}
|
||||
|
||||
// this is for display in available plugins
|
||||
@@ -53,13 +53,13 @@ export interface PluginItem {
|
||||
|
||||
export interface Plugin extends PluginItem {
|
||||
path: string; // path in device
|
||||
filters?: SourceFilter[];
|
||||
filters?: Filters;
|
||||
userAgent: string;
|
||||
cookieString: string;
|
||||
rawCode: string;
|
||||
popularNovels: (
|
||||
pageNo: number,
|
||||
options?: SourceOptions,
|
||||
options?: PopularNovelsOptions<Filters>,
|
||||
) => Promise<NovelItem[]>;
|
||||
parseNovelAndChapters: (
|
||||
novelUrl: string,
|
||||
|
||||
@@ -156,7 +156,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showLatestNovels && filterValues?.length ? (
|
||||
{!showLatestNovels && filterValues ? (
|
||||
<>
|
||||
<FAB
|
||||
icon={'filter-variant'}
|
||||
@@ -171,7 +171,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
|
||||
/>
|
||||
<FilterBottomSheet
|
||||
filterSheetRef={filterSheetRef}
|
||||
filtersValues={filterValues}
|
||||
filters={filterValues}
|
||||
setFilters={setFilters}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
|
||||
@@ -6,39 +6,44 @@ import { BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet';
|
||||
|
||||
import { useTheme } from '@hooks/useTheme';
|
||||
import {
|
||||
FilterInputs,
|
||||
SelectedFilter,
|
||||
SourceFilter,
|
||||
FilterTypes,
|
||||
FilterToValues,
|
||||
Filters,
|
||||
} from '@plugins/types/filterTypes';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { Button, IconButtonV2 } from '@components/index';
|
||||
import { Checkbox } from '@components/Checkbox/Checkbox';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import useBoolean from '@hooks/useBoolean';
|
||||
import { overlay } from 'react-native-paper';
|
||||
import { getValueFor } from './filterUtils';
|
||||
|
||||
const insertOrRemoveIntoArray = (array: string[], val: string): string[] =>
|
||||
array.indexOf(val) > -1 ? array.filter(ele => ele !== val) : [...array, val];
|
||||
|
||||
type SelectedFilters = FilterToValues<Filters>;
|
||||
|
||||
interface FilterItemProps {
|
||||
filter: SourceFilter;
|
||||
selectedFilters: SelectedFilter | undefined;
|
||||
setSelectedFilters: React.Dispatch<
|
||||
React.SetStateAction<SelectedFilter | undefined>
|
||||
>;
|
||||
filter: Filters[string];
|
||||
filterKey: keyof Filters;
|
||||
selectedFilters: SelectedFilters;
|
||||
setSelectedFilters: React.Dispatch<React.SetStateAction<SelectedFilters>>;
|
||||
}
|
||||
|
||||
const FilterItem: React.FC<FilterItemProps> = ({
|
||||
filter,
|
||||
filterKey,
|
||||
selectedFilters,
|
||||
setSelectedFilters,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { value: isVisible, toggle: toggleCard } = useBoolean();
|
||||
|
||||
if (filter.inputType === FilterInputs.Picker) {
|
||||
if (filter.type === FilterTypes.Picker) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
selectedFilters[filterKey],
|
||||
);
|
||||
return (
|
||||
<View style={styles.pickerContainer}>
|
||||
<Text style={[styles.pickerLabel, { color: theme.onSurfaceVariant }]}>
|
||||
@@ -47,17 +52,17 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
<Picker
|
||||
style={styles.picker}
|
||||
mode="dropdown"
|
||||
selectedValue={selectedFilters?.[filter.key] as string}
|
||||
selectedValue={value}
|
||||
dropdownIconRippleColor={theme.primary}
|
||||
onValueChange={(itemValue: string) =>
|
||||
setSelectedFilters(prevFilters => ({
|
||||
...prevFilters,
|
||||
[filter.key]: itemValue,
|
||||
[filterKey]: { value: itemValue, type: FilterTypes.Picker },
|
||||
}))
|
||||
}
|
||||
placeholder={filter.label}
|
||||
>
|
||||
{filter.values.map(val => {
|
||||
{filter.options.map(val => {
|
||||
return (
|
||||
<Picker.Item
|
||||
key={val.label}
|
||||
@@ -71,10 +76,11 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.inputType === FilterInputs.Checkbox) {
|
||||
const isChecked = (val: string) =>
|
||||
(selectedFilters?.[filter.key] as string[])?.includes(val);
|
||||
|
||||
if (filter.type === FilterTypes.CheckboxGroup) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
selectedFilters[filterKey],
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
@@ -92,19 +98,20 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
/>
|
||||
</Pressable>
|
||||
{isVisible
|
||||
? filter.values.map(val => {
|
||||
? filter.options.map(val => {
|
||||
return (
|
||||
<Checkbox
|
||||
key={val.label}
|
||||
label={val.label}
|
||||
theme={theme}
|
||||
status={isChecked(val.value)}
|
||||
status={value.includes(val.value)}
|
||||
onPress={() =>
|
||||
setSelectedFilters(prevFilters => ({
|
||||
...prevFilters,
|
||||
[filter.key]: insertOrRemoveIntoArray(
|
||||
defaultTo(prevFilters?.[filter.key] as string[], []),
|
||||
val.value,
|
||||
),
|
||||
[filterKey]: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
value: insertOrRemoveIntoArray(value, val.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
@@ -115,26 +122,119 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.type === FilterTypes.ExcludableCheckboxGroup) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
selectedFilters[filterKey],
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
style={styles.checkboxHeader}
|
||||
onPress={toggleCard}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
>
|
||||
<Text style={[styles.filterLabel, { color: theme.onSurfaceVariant }]}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
<IconButtonV2
|
||||
name={isVisible ? 'chevron-up' : 'chevron-down'}
|
||||
onPress={toggleCard}
|
||||
theme={theme}
|
||||
/>
|
||||
</Pressable>
|
||||
{isVisible
|
||||
? filter.options.map(val => {
|
||||
return (
|
||||
<Checkbox
|
||||
key={val.label}
|
||||
label={val.label}
|
||||
theme={theme}
|
||||
status={
|
||||
value.include?.includes(val.value)
|
||||
? true
|
||||
: value.exclude?.includes(val.value)
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onPress={() => {
|
||||
if (value.exclude?.includes(val.value)) {
|
||||
setSelectedFilters(prev => {
|
||||
return {
|
||||
...prev,
|
||||
[filterKey]: {
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
value: {
|
||||
include: [...(value.include || [])],
|
||||
exclude: [
|
||||
...(value.exclude?.filter(
|
||||
f => f !== val.value,
|
||||
) || []),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (value.include?.includes(val.value)) {
|
||||
setSelectedFilters(prev => {
|
||||
return {
|
||||
...prev,
|
||||
[filterKey]: {
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
value: {
|
||||
include: [
|
||||
...(value.include?.filter(
|
||||
f => f !== val.value,
|
||||
) || []),
|
||||
],
|
||||
exclude: [...(value.exclude || []), val.value],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setSelectedFilters(prev => {
|
||||
return {
|
||||
...prev,
|
||||
[filterKey]: {
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
value: {
|
||||
include: [...(value.include || []), val.value],
|
||||
exclude: value.exclude,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
interface BottomSheetProps {
|
||||
filterSheetRef: React.RefObject<BottomSheetModal> | null;
|
||||
filtersValues: SourceFilter[] | undefined;
|
||||
setFilters: (filters?: SelectedFilter) => void;
|
||||
clearFilters: () => void;
|
||||
filters: Filters;
|
||||
setFilters: (filters?: SelectedFilters) => void;
|
||||
clearFilters: (filters: Filters) => void;
|
||||
}
|
||||
|
||||
const FilterBottomSheet: React.FC<BottomSheetProps> = ({
|
||||
filters,
|
||||
filterSheetRef,
|
||||
filtersValues,
|
||||
clearFilters,
|
||||
setFilters,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [selectedFilters, setSelectedFilters] = useState<
|
||||
SelectedFilter | undefined
|
||||
>();
|
||||
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<SelectedFilters>(filters);
|
||||
|
||||
return (
|
||||
<BottomSheet bottomSheetRef={filterSheetRef} snapPoints={[400, 600]}>
|
||||
@@ -150,8 +250,8 @@ const FilterBottomSheet: React.FC<BottomSheetProps> = ({
|
||||
<Button
|
||||
title={'Reset'}
|
||||
onPress={() => {
|
||||
setSelectedFilters(undefined);
|
||||
clearFilters();
|
||||
setSelectedFilters(filters);
|
||||
clearFilters(filters);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -166,11 +266,12 @@ const FilterBottomSheet: React.FC<BottomSheetProps> = ({
|
||||
</BottomSheetView>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.filterContainer}
|
||||
data={filtersValues}
|
||||
keyExtractor={(item, index) => 'filter' + index}
|
||||
renderItem={({ item }: { item: SourceFilter }) => (
|
||||
data={filters && Object.entries(filters)}
|
||||
keyExtractor={item => 'filter' + item[0]}
|
||||
renderItem={({ item }) => (
|
||||
<FilterItem
|
||||
filter={item}
|
||||
filter={item[1]}
|
||||
filterKey={item[0]}
|
||||
selectedFilters={selectedFilters}
|
||||
setSelectedFilters={setSelectedFilters}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
FilterToValues,
|
||||
FilterTypes,
|
||||
Filters,
|
||||
isCheckboxValue,
|
||||
isPickerValue,
|
||||
isSwitchValue,
|
||||
isTextValue,
|
||||
ValueOfFilter,
|
||||
isXCheckboxValue,
|
||||
} from '@plugins/types/filterTypes';
|
||||
|
||||
export const getValueFor = <T extends FilterTypes>(
|
||||
filter: Filters[string],
|
||||
value: FilterToValues<Filters>[string],
|
||||
): ValueOfFilter<T> => {
|
||||
switch (filter.type) {
|
||||
case FilterTypes.CheckboxGroup:
|
||||
return (
|
||||
isCheckboxValue(value) ? value.value : filter.value
|
||||
) as ValueOfFilter<T>;
|
||||
case FilterTypes.Picker:
|
||||
return (
|
||||
isPickerValue(value) ? (value.value as ValueOfFilter<T>) : filter.value
|
||||
) as ValueOfFilter<T>;
|
||||
case FilterTypes.Switch:
|
||||
return (
|
||||
isSwitchValue(value) ? value.value : filter.value
|
||||
) as ValueOfFilter<T>;
|
||||
case FilterTypes.TextInput:
|
||||
return (
|
||||
isTextValue(value) ? value.value : filter.value
|
||||
) as ValueOfFilter<T>;
|
||||
case FilterTypes.ExcludableCheckboxGroup:
|
||||
return (
|
||||
isXCheckboxValue(value) ? value.value : filter.value
|
||||
) as ValueOfFilter<T>;
|
||||
default:
|
||||
throw 'Invalid filter type!';
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { SelectedFilter, SourceFilter } from '@plugins/types/filterTypes';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { FilterToValues, Filters } from '@plugins/types/filterTypes';
|
||||
|
||||
export const useBrowseSource = (
|
||||
pluginId: string,
|
||||
@@ -13,16 +13,18 @@ export const useBrowseSource = (
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [filterValues, setFilterValues] = useState<SourceFilter[]>();
|
||||
const [filterValues, setFilterValues] = useState<Filters | undefined>(
|
||||
getPlugin(pluginId).filters,
|
||||
);
|
||||
const [selectedFilters, setSelectedFilters] = useState<
|
||||
SelectedFilter | undefined
|
||||
>();
|
||||
FilterToValues<Filters> | undefined
|
||||
>(filterValues);
|
||||
const [hasNextPage, setHasNextPage] = useState(true);
|
||||
|
||||
const isScreenMounted = useRef(true);
|
||||
|
||||
const fetchNovels = useCallback(
|
||||
async (page: number, filters?: SelectedFilter) => {
|
||||
async (page: number, filters?: FilterToValues<Filters>) => {
|
||||
if (isScreenMounted.current === true) {
|
||||
try {
|
||||
const plugin = getPlugin(pluginId);
|
||||
@@ -67,9 +69,12 @@ export const useBrowseSource = (
|
||||
fetchNovels(1);
|
||||
};
|
||||
|
||||
const clearFilters = useCallback(() => setSelectedFilters(undefined), []);
|
||||
const clearFilters = useCallback(
|
||||
(filters: Filters) => setSelectedFilters(filters),
|
||||
[],
|
||||
);
|
||||
|
||||
const setFilters = (filters?: SelectedFilter) => {
|
||||
const setFilters = (filters?: FilterToValues<Filters>) => {
|
||||
setIsLoading(true);
|
||||
setCurrentPage(1);
|
||||
fetchNovels(1, filters);
|
||||
|
||||
Reference in New Issue
Block a user