Filters Redesign (#856)

New filters system adding intelisense

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
This commit is contained in:
Marcin Majewski
2023-12-27 15:06:45 +01:00
committed by GitHub
parent 5c12540071
commit 7262f1d249
7 changed files with 324 additions and 73 deletions
+9 -7
View File
@@ -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`,
+117 -15
View File
@@ -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)
);
};
+5 -5
View File
@@ -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);