Add memes views & searching
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
@@ -13,11 +13,7 @@ import styles from '../styles';
|
||||
import { ROUTE, RootStackParamList } from '../types';
|
||||
import { Meme, Tag } from '../database';
|
||||
import { RootState } from '../state';
|
||||
import {
|
||||
getMemeType,
|
||||
validateMemeDescription,
|
||||
validateMemeTitle,
|
||||
} from '../utilities';
|
||||
import { getMemeType, validateMemeTitle } from '../utilities';
|
||||
import { MemeEditor } from '../components';
|
||||
|
||||
const AddMeme = ({
|
||||
@@ -35,9 +31,6 @@ const AddMeme = ({
|
||||
const { file } = route.params;
|
||||
|
||||
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
|
||||
const [memeDescription, setMemeDescription] = useState(
|
||||
validateMemeDescription(''),
|
||||
);
|
||||
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
|
||||
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
|
||||
|
||||
@@ -69,7 +62,6 @@ const AddMeme = ({
|
||||
uri,
|
||||
size,
|
||||
title: memeTitle.parsed,
|
||||
description: memeDescription.parsed,
|
||||
isFavorite: memeIsFavorite,
|
||||
tags: [...memeTags.values()],
|
||||
tagsLength: memeTags.size,
|
||||
@@ -77,9 +69,8 @@ const AddMeme = ({
|
||||
|
||||
memeTags.forEach(tag => {
|
||||
tag.dateModified = new Date();
|
||||
const memes = tag.memes as Realm.Set<Meme>;
|
||||
memes.add(meme);
|
||||
tag.memesLength = memes.size;
|
||||
tag.memes.push(meme);
|
||||
tag.memesLength = tag.memes.length;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,8 +102,6 @@ const AddMeme = ({
|
||||
imageUri={file.uri}
|
||||
memeTitle={memeTitle}
|
||||
setMemeTitle={setMemeTitle}
|
||||
memeDescription={memeDescription}
|
||||
setMemeDescription={setMemeDescription}
|
||||
memeTags={memeTags}
|
||||
setMemeTags={setMemeTags}
|
||||
/>
|
||||
@@ -122,7 +111,7 @@ const AddMeme = ({
|
||||
mode="contained"
|
||||
icon="floppy"
|
||||
onPress={handleSave}
|
||||
disabled={!memeTitle.valid || !memeDescription.valid || isSaving}
|
||||
disabled={!memeTitle.valid || isSaving}
|
||||
loading={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
|
@@ -10,7 +10,7 @@ import { ORIENTATION, useDimensions } from '../contexts';
|
||||
import styles from '../styles';
|
||||
import { RootStackParamList, ROUTE } from '../types';
|
||||
import { Tag, Meme } from '../database';
|
||||
import { validateMemeTitle, validateMemeDescription } from '../utilities';
|
||||
import { validateMemeTitle } from '../utilities';
|
||||
import { MemeEditor } from '../components';
|
||||
|
||||
const EditMeme = ({
|
||||
@@ -28,14 +28,8 @@ const EditMeme = ({
|
||||
)!;
|
||||
|
||||
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
|
||||
const [memeDescription, setMemeDescription] = useState(
|
||||
validateMemeDescription(meme.description),
|
||||
);
|
||||
const [memeIsFavorite, setMemeIsFavorite] = useState(meme.isFavorite);
|
||||
const [memeTags, setMemeTags] = useState(
|
||||
new Map<string, Tag>(
|
||||
(meme.tags as Realm.Set<Tag>).map(tag => [tag.id.toHexString(), tag]),
|
||||
),
|
||||
new Map<string, Tag>(meme.tags.map(tag => [tag.id.toHexString(), tag])),
|
||||
);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -46,24 +40,22 @@ const EditMeme = ({
|
||||
realm.write(() => {
|
||||
meme.tags.forEach(tag => {
|
||||
if (!memeTags.has(tag.id.toHexString())) {
|
||||
const memes = tag.memes as Realm.Set<Meme>;
|
||||
memes.delete(meme);
|
||||
tag.memesLength = memes.size;
|
||||
tag.memes.slice(tag.memes.indexOf(meme), 1);
|
||||
tag.memesLength -= 1;
|
||||
tag.dateModified = new Date();
|
||||
}
|
||||
});
|
||||
|
||||
memeTags.forEach(tag => {
|
||||
if (!(meme.tags as Realm.Set<Tag>).has(tag)) {
|
||||
const memes = tag.memes as Realm.Set<Meme>;
|
||||
memes.add(meme);
|
||||
tag.memesLength = memes.size;
|
||||
if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) {
|
||||
tag.memes.push(meme);
|
||||
tag.memesLength = tag.memes.length;
|
||||
tag.dateModified = new Date();
|
||||
}
|
||||
});
|
||||
|
||||
meme.title = memeTitle.parsed;
|
||||
meme.description = memeDescription.parsed;
|
||||
// @ts-expect-error - Realm is a fuck
|
||||
meme.tags = [...memeTags.values()];
|
||||
meme.tagsLength = memeTags.size;
|
||||
meme.dateModified = new Date();
|
||||
@@ -74,9 +66,8 @@ const EditMeme = ({
|
||||
|
||||
const handleFavorite = () => {
|
||||
realm.write(() => {
|
||||
meme.isFavorite = !memeIsFavorite;
|
||||
meme.isFavorite = !meme.isFavorite;
|
||||
});
|
||||
setMemeIsFavorite(!memeIsFavorite);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -86,9 +77,8 @@ const EditMeme = ({
|
||||
realm.write(() => {
|
||||
for (const tag of meme.tags) {
|
||||
tag.dateModified = new Date();
|
||||
const memes = tag.memes as Realm.Set<Meme>;
|
||||
memes.delete(meme);
|
||||
tag.memesLength = memes.size;
|
||||
tag.memes.slice(tag.memes.indexOf(meme), 1);
|
||||
tag.memesLength -= 1;
|
||||
}
|
||||
|
||||
realm.delete(meme);
|
||||
@@ -103,7 +93,7 @@ const EditMeme = ({
|
||||
<Appbar.BackAction onPress={() => goBack()} />
|
||||
<Appbar.Content title={'Edit Meme'} />
|
||||
<Appbar.Action
|
||||
icon={memeIsFavorite ? 'heart' : 'heart-outline'}
|
||||
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
|
||||
onPress={handleFavorite}
|
||||
/>
|
||||
<Appbar.Action icon="delete" onPress={handleDelete} />
|
||||
@@ -123,8 +113,6 @@ const EditMeme = ({
|
||||
imageUri={meme.uri}
|
||||
memeTitle={memeTitle}
|
||||
setMemeTitle={setMemeTitle}
|
||||
memeDescription={memeDescription}
|
||||
setMemeDescription={setMemeDescription}
|
||||
memeTags={memeTags}
|
||||
setMemeTags={setMemeTags}
|
||||
/>
|
||||
@@ -134,7 +122,7 @@ const EditMeme = ({
|
||||
mode="contained"
|
||||
icon="floppy"
|
||||
onPress={handleSave}
|
||||
disabled={!memeTitle.valid || !memeDescription.valid || isSaving}
|
||||
disabled={!memeTitle.valid || isSaving}
|
||||
loading={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
|
@@ -43,9 +43,8 @@ const EditTag = ({
|
||||
realm.write(() => {
|
||||
for (const meme of tag.memes) {
|
||||
meme.dateModified = new Date();
|
||||
const tags = meme.tags as Realm.Set<Tag>;
|
||||
tags.delete(tag);
|
||||
meme.tagsLength = tags.size;
|
||||
meme.tags.slice(meme.tags.indexOf(tag), 1);
|
||||
meme.tagsLength -= 1;
|
||||
}
|
||||
|
||||
realm.delete(tag);
|
||||
|
@@ -1,38 +1,79 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { RefObject, useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
BackHandler,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useQuery } from '@realm/react';
|
||||
import { useTheme, HelperText } from 'react-native-paper';
|
||||
import { useTheme } from 'react-native-paper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import styles from '../styles';
|
||||
import { SORT_DIRECTION, memesSortQuery } from '../types';
|
||||
import { SORT_DIRECTION, VIEW, memesSortQuery } from '../types';
|
||||
import { RootState, setNavVisible } from '../state';
|
||||
import { Meme } from '../database';
|
||||
import { ORIENTATION, useDimensions } from '../contexts';
|
||||
import { HideableHeader, MemesHeader } from '../components';
|
||||
import MemeCard from '../components/memes/memeCard';
|
||||
import {
|
||||
HideableHeader,
|
||||
MemesHeader,
|
||||
MemesMasonryView,
|
||||
MemesGridView,
|
||||
MemesListView,
|
||||
} from '../components';
|
||||
|
||||
const memesStyles = StyleSheet.create({
|
||||
helperText: {
|
||||
marginVertical: 10,
|
||||
},
|
||||
flashList: {
|
||||
paddingBottom: 100,
|
||||
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876
|
||||
paddingHorizontal: 0.01,
|
||||
},
|
||||
});
|
||||
const MemesView = ({
|
||||
memes,
|
||||
flashListRef,
|
||||
flashListPadding,
|
||||
handleScroll,
|
||||
}: {
|
||||
memes: Realm.Results<Meme & Realm.Object<Meme>>;
|
||||
flashListRef: RefObject<FlashList<Meme>>;
|
||||
flashListPadding: number;
|
||||
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
}) => {
|
||||
const view = useSelector((state: RootState) => state.memes.view);
|
||||
|
||||
switch (view) {
|
||||
case VIEW.MASONRY: {
|
||||
return (
|
||||
<MemesMasonryView
|
||||
memes={memes}
|
||||
flashListRef={flashListRef}
|
||||
flashListPadding={flashListPadding}
|
||||
handleScroll={handleScroll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case VIEW.GRID: {
|
||||
return (
|
||||
<MemesGridView
|
||||
memes={memes}
|
||||
flashListRef={flashListRef}
|
||||
flashListPadding={flashListPadding}
|
||||
handleScroll={handleScroll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case VIEW.LIST: {
|
||||
return (
|
||||
<MemesListView
|
||||
memes={memes}
|
||||
flashListRef={flashListRef}
|
||||
flashListPadding={flashListPadding}
|
||||
handleScroll={handleScroll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Memes = () => {
|
||||
const { colors } = useTheme();
|
||||
const { dimensions, orientation } = useDimensions();
|
||||
const sort = useSelector((state: RootState) => state.memes.sort);
|
||||
const sortDirection = useSelector(
|
||||
(state: RootState) => state.memes.sortDirection,
|
||||
@@ -54,10 +95,31 @@ const Memes = () => {
|
||||
collectionIn => {
|
||||
let collection = collectionIn;
|
||||
|
||||
const tokens = search
|
||||
.match(/"[^"]+"|\S+/gi)
|
||||
?.map(token => token.replaceAll(/["']/g, ''));
|
||||
|
||||
const tags = tokens
|
||||
?.filter(token => token.startsWith('#'))
|
||||
.map(tag => tag.slice(1));
|
||||
|
||||
const words = tokens?.filter(token => !token.startsWith('#'));
|
||||
|
||||
const tagsQuery = tags
|
||||
?.map((tag, index) => `ANY tags.name CONTAINS[c] $${index}`)
|
||||
.join(' OR ');
|
||||
|
||||
const wordsQuery = words
|
||||
?.map((word, index) => `title CONTAINS[c] $${index}`)
|
||||
.join(' OR ');
|
||||
|
||||
if (favoritesOnly) collection = collection.filtered('isFavorite == true');
|
||||
if (filter) collection = collection.filtered('type == $0', filter);
|
||||
if (search) {
|
||||
collection = collection.filtered('title CONTAINS[c] $0', search);
|
||||
if (tags && tagsQuery) {
|
||||
collection = collection.filtered(tagsQuery, ...tags);
|
||||
}
|
||||
if (words && wordsQuery) {
|
||||
collection = collection.filtered(wordsQuery, ...words);
|
||||
}
|
||||
|
||||
collection = collection.sorted(
|
||||
@@ -79,7 +141,9 @@ const Memes = () => {
|
||||
dispatch(setNavVisible(true));
|
||||
} else {
|
||||
const diff = currentOffset - scrollOffset;
|
||||
if (Math.abs(diff) > 50) dispatch(setNavVisible(diff < 0));
|
||||
if (Math.abs(diff) > 50) {
|
||||
dispatch(setNavVisible(diff < 0));
|
||||
}
|
||||
}
|
||||
|
||||
setScrollOffset(currentOffset);
|
||||
@@ -89,7 +153,6 @@ const Memes = () => {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(setNavVisible(true));
|
||||
const handleBackPress = () => {
|
||||
if (scrollOffset > 0) {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
@@ -102,7 +165,13 @@ const Memes = () => {
|
||||
|
||||
return () =>
|
||||
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
|
||||
}, [dispatch, scrollOffset]),
|
||||
}, [scrollOffset]),
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(setNavVisible(true));
|
||||
}, [dispatch]),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -121,32 +190,11 @@ const Memes = () => {
|
||||
}}
|
||||
/>
|
||||
</HideableHeader>
|
||||
<MasonryFlashList
|
||||
ref={flashListRef}
|
||||
data={memes}
|
||||
estimatedItemSize={200}
|
||||
estimatedListSize={{
|
||||
height: dimensions.height,
|
||||
width: dimensions.width * 0.92,
|
||||
}}
|
||||
numColumns={2}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item: meme }) => <MemeCard meme={meme} />}
|
||||
contentContainerStyle={{
|
||||
paddingTop:
|
||||
flashListPadding +
|
||||
dimensions.height *
|
||||
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
|
||||
...memesStyles.flashList,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<HelperText
|
||||
type={'info'}
|
||||
style={[memesStyles.helperText, styles.centerText]}>
|
||||
No memes found
|
||||
</HelperText>
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
<MemesView
|
||||
memes={memes}
|
||||
flashListRef={flashListRef}
|
||||
flashListPadding={flashListPadding}
|
||||
handleScroll={handleScroll}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
Button,
|
||||
List,
|
||||
Portal,
|
||||
SegmentedButtons,
|
||||
Snackbar,
|
||||
Switch,
|
||||
Text,
|
||||
@@ -13,19 +14,34 @@ import { openDocumentTree } from 'react-native-scoped-storage';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type {} from 'redux-thunk/extend-redux';
|
||||
import styles from '../styles';
|
||||
import { RootState, setNoMedia, setStorageUri } from '../state';
|
||||
import {
|
||||
RootState,
|
||||
setGridColumns,
|
||||
setMasonryColumns,
|
||||
setNoMedia,
|
||||
setStorageUri,
|
||||
} from '../state';
|
||||
import { ORIENTATION, useDimensions } from '../contexts';
|
||||
|
||||
const settingsScreenStyles = StyleSheet.create({
|
||||
const settingsStyles = StyleSheet.create({
|
||||
snackbar: {
|
||||
marginBottom: 90,
|
||||
},
|
||||
marginBottom: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const SettingsScreen = () => {
|
||||
const Settings = () => {
|
||||
const { colors } = useTheme();
|
||||
const { orientation, responsive } = useDimensions();
|
||||
const { orientation } = useDimensions();
|
||||
const noMedia = useSelector((state: RootState) => state.settings.noMedia);
|
||||
const masonryColumns = useSelector(
|
||||
(state: RootState) => state.settings.masonryColumns,
|
||||
);
|
||||
const gridColumns = useSelector(
|
||||
(state: RootState) => state.settings.gridColumns,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false);
|
||||
@@ -52,21 +68,56 @@ const SettingsScreen = () => {
|
||||
]}>
|
||||
<View>
|
||||
<List.Section>
|
||||
<List.Subheader>Database</List.Subheader>
|
||||
<Button
|
||||
mode="elevated"
|
||||
loading={isOptimizingDatabase}
|
||||
onPress={optimizeDatabase}>
|
||||
Optimize Database Now
|
||||
</Button>
|
||||
<List.Subheader>Views</List.Subheader>
|
||||
<Text
|
||||
style={[
|
||||
settingsStyles.marginBottom,
|
||||
styles.smallPaddingHorizontal,
|
||||
]}>
|
||||
Masonry Columns
|
||||
</Text>
|
||||
<SegmentedButtons
|
||||
value={masonryColumns.toString()}
|
||||
onValueChange={value => {
|
||||
void dispatch(
|
||||
setMasonryColumns(Number.parseInt(value) as 1 | 2 | 3 | 4),
|
||||
);
|
||||
}}
|
||||
buttons={[
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
]}
|
||||
style={settingsStyles.marginBottom}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
settingsStyles.marginBottom,
|
||||
styles.smallPaddingHorizontal,
|
||||
]}>
|
||||
Grid Columns
|
||||
</Text>
|
||||
<SegmentedButtons
|
||||
value={gridColumns.toString()}
|
||||
onValueChange={value => {
|
||||
void dispatch(
|
||||
setGridColumns(Number.parseInt(value) as 1 | 2 | 3 | 4),
|
||||
);
|
||||
}}
|
||||
buttons={[
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
]}
|
||||
/>
|
||||
</List.Section>
|
||||
<List.Section>
|
||||
<List.Subheader>Media Storage</List.Subheader>
|
||||
<Button
|
||||
mode="elevated"
|
||||
style={{
|
||||
marginBottom: responsive.verticalScale(15),
|
||||
}}
|
||||
style={settingsStyles.marginBottom}
|
||||
onPress={async () => {
|
||||
const { uri } = await openDocumentTree(true);
|
||||
void dispatch(setStorageUri(uri));
|
||||
@@ -77,9 +128,6 @@ const SettingsScreen = () => {
|
||||
style={[
|
||||
styles.flexRowSpaceBetween,
|
||||
styles.smallPaddingHorizontal,
|
||||
{
|
||||
marginBottom: responsive.verticalScale(15),
|
||||
},
|
||||
]}>
|
||||
<Text>Hide media from gallery</Text>
|
||||
<Switch
|
||||
@@ -91,12 +139,21 @@ const SettingsScreen = () => {
|
||||
</View>
|
||||
</List.Section>
|
||||
</View>
|
||||
<List.Section>
|
||||
<List.Subheader>Database</List.Subheader>
|
||||
<Button
|
||||
mode="elevated"
|
||||
loading={isOptimizingDatabase}
|
||||
onPress={optimizeDatabase}>
|
||||
Optimize Database Now
|
||||
</Button>
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
visible={snackbarVisible}
|
||||
onDismiss={() => setSnackbarVisible(false)}
|
||||
style={settingsScreenStyles.snackbar}
|
||||
style={settingsStyles.snackbar}
|
||||
action={{
|
||||
label: 'Dismiss',
|
||||
onPress: () => setSnackbarVisible(false),
|
||||
@@ -108,4 +165,4 @@ const SettingsScreen = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsScreen;
|
||||
export default Settings;
|
||||
|
@@ -80,7 +80,6 @@ const Tags = () => {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(setNavVisible(true));
|
||||
const handleBackPress = () => {
|
||||
if (scrollOffset > 0) {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
@@ -93,7 +92,13 @@ const Tags = () => {
|
||||
|
||||
return () =>
|
||||
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
|
||||
}, [dispatch, scrollOffset]),
|
||||
}, [scrollOffset]),
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(setNavVisible(true));
|
||||
}, [dispatch]),
|
||||
);
|
||||
|
||||
return (
|
||||
|
Reference in New Issue
Block a user