Add meme-adding logic
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
@@ -3,8 +3,10 @@ import { Keyboard } from 'react-native';
|
||||
import { FAB } from 'react-native-paper';
|
||||
import { ParamListBase, useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { pick } from 'react-native-document-picker';
|
||||
import { useDimensions } from '../contexts';
|
||||
import { ROUTE } from '../types';
|
||||
import { allowedMimeTypes, noOp } from '../utilities';
|
||||
|
||||
const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
|
||||
const { navigate } =
|
||||
@@ -39,22 +41,34 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
|
||||
{
|
||||
icon: 'tag',
|
||||
label: 'Tag',
|
||||
onPress: () => navigate(ROUTE.EDIT_TAG),
|
||||
onPress: () => navigate(ROUTE.ADD_TAG),
|
||||
},
|
||||
{
|
||||
icon: 'note-text',
|
||||
label: 'Text',
|
||||
onPress: () => navigate(ROUTE.EDIT_MEME),
|
||||
onPress: () => {
|
||||
throw new Error('Not yet implemented');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'image-album',
|
||||
label: 'Album',
|
||||
onPress: () => navigate(ROUTE.EDIT_MEME),
|
||||
onPress: async () => {
|
||||
const res = await pick({
|
||||
allowMultiSelection: true,
|
||||
type: allowedMimeTypes,
|
||||
}).catch(noOp);
|
||||
if (!res) return;
|
||||
navigate(ROUTE.ADD_MEME, { uri: res });
|
||||
},
|
||||
},
|
||||
]}
|
||||
onStateChange={({ open }) => setState(open)}
|
||||
onPress={() => {
|
||||
if (state) navigate(ROUTE.EDIT_MEME);
|
||||
onPress={async () => {
|
||||
if (!state) return;
|
||||
const res = await pick({ type: allowedMimeTypes }).catch(noOp);
|
||||
if (!res) return;
|
||||
navigate(ROUTE.ADD_MEME, { uri: res });
|
||||
}}
|
||||
style={{
|
||||
paddingBottom: responsive.verticalScale(75),
|
||||
|
@@ -10,7 +10,7 @@ const hideableHeaderStyles = StyleSheet.create({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
export { MemeEditor } from './memes';
|
||||
export { TagChip, TagEditor, TagPreview } from './tags';
|
||||
export { default as FloatingActionButton } from './floatingActionButton';
|
||||
export { default as HideableBottomNavigationBar } from './hideableBottomNavigationBar';
|
||||
export { default as HideableHeader } from './hideableHeader';
|
||||
export { default as LoadingView } from './loadingView';
|
||||
export { default as TagChip } from './tags/tagChip';
|
||||
export { default as TagPreview } from './tags/tagPreview';
|
||||
|
3
src/components/memes/index.ts
Normal file
3
src/components/memes/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MemeEditor } from './memeEditor';
|
||||
export { default as MemeTagSearchModal } from './memeTagSearchModal';
|
||||
export { default as MemeTagSelector } from './memeTagSelector';
|
99
src/components/memes/memeEditor.tsx
Normal file
99
src/components/memes/memeEditor.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { HelperText, TextInput } from 'react-native-paper';
|
||||
import { Image } from 'react-native';
|
||||
import { useDimensions } from '../../contexts';
|
||||
import LoadingView from '../loadingView';
|
||||
import { MemeTagSelector } from '.';
|
||||
import { Tag } from '../../database';
|
||||
|
||||
const MemeEditor = ({
|
||||
imageUri,
|
||||
memeTitle,
|
||||
setMemeTitle,
|
||||
memeDescription,
|
||||
setMemeDescription,
|
||||
memeTags,
|
||||
setMemeTags,
|
||||
memeTitleError,
|
||||
setMemeTitleError,
|
||||
}: {
|
||||
imageUri: string[];
|
||||
memeTitle: string;
|
||||
setMemeTitle: (name: string) => void;
|
||||
memeDescription: string;
|
||||
setMemeDescription: (description: string) => void;
|
||||
memeTags: Map<string, Tag>;
|
||||
setMemeTags: (tags: Map<string, Tag>) => void;
|
||||
memeTitleError: string | undefined;
|
||||
setMemeTitleError: (error: string | undefined) => void;
|
||||
}) => {
|
||||
const { dimensions, fixed, responsive } = useDimensions();
|
||||
|
||||
const [imageWidth, setImageWidth] = useState<number>();
|
||||
const [imageHeight, setImageHeight] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
Image.getSize(imageUri[0], (width, height) => {
|
||||
const paddedWidth = dimensions.width - dimensions.width * 0.08;
|
||||
setImageWidth(paddedWidth);
|
||||
setImageHeight((paddedWidth / width) * height);
|
||||
});
|
||||
}, [dimensions.width, imageUri]);
|
||||
|
||||
const handleMemeTitleChange = (name: string) => {
|
||||
setMemeTitle(name);
|
||||
if (name.length === 0) {
|
||||
setMemeTitleError('Meme title cannot be empty');
|
||||
} else {
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
setMemeTitleError(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (!imageWidth || !imageHeight) return <LoadingView />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Title"
|
||||
value={memeTitle}
|
||||
onChangeText={handleMemeTitleChange}
|
||||
error={!!memeTitleError}
|
||||
selectTextOnFocus
|
||||
/>
|
||||
<HelperText type="error" visible={!!memeTitleError}>
|
||||
{memeTitleError}
|
||||
</HelperText>
|
||||
<Image
|
||||
source={{ uri: imageUri[0] }}
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
marginBottom: fixed.verticalScale(10),
|
||||
borderRadius: fixed.verticalScale(5),
|
||||
}}
|
||||
/>
|
||||
<MemeTagSelector
|
||||
memeTags={memeTags}
|
||||
setMemeTags={setMemeTags}
|
||||
style={{
|
||||
marginBottom: responsive.verticalScale(10),
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Description"
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
style={{
|
||||
marginBottom: responsive.verticalScale(15),
|
||||
}}
|
||||
value={memeDescription}
|
||||
onChangeText={setMemeDescription}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeEditor;
|
128
src/components/memes/memeTagSearchModal.tsx
Normal file
128
src/components/memes/memeTagSearchModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { TagChip } from '../tags';
|
||||
import { Tag } from '../../database';
|
||||
import { useQuery, useRealm } from '@realm/react';
|
||||
import { TAG_SORT, tagSortQuery } from '../../types';
|
||||
import { Chip, Modal, Portal, Searchbar, useTheme } from 'react-native-paper';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { useDimensions } from '../../contexts';
|
||||
import styles from '../../styles';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
|
||||
const memeTagSearchModalStyles = StyleSheet.create({
|
||||
modal: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const MemeTagSearchModal = ({
|
||||
visible,
|
||||
setVisible,
|
||||
memeTags,
|
||||
setMemeTags,
|
||||
}: {
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
memeTags: Map<string, Tag>;
|
||||
setMemeTags: (tags: Map<string, Tag>) => void;
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { fixed, responsive } = useDimensions();
|
||||
const realm = useRealm();
|
||||
|
||||
const flashListRef = useRef<FlashList<Tag>>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleSearch = (newSearch: string) => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0 });
|
||||
setSearch(newSearch);
|
||||
};
|
||||
|
||||
const tags = useQuery<Tag>(
|
||||
Tag.schema.name,
|
||||
collection =>
|
||||
collection
|
||||
.filtered(`name CONTAINS[c] "${search}"`)
|
||||
.sorted(tagSortQuery(TAG_SORT.DATE_MODIFIED), true),
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTagPress = (tag: Tag) => {
|
||||
const id = tag.id.toHexString();
|
||||
memeTags.delete(id) || memeTags.set(id, tag);
|
||||
setMemeTags(new Map(memeTags));
|
||||
};
|
||||
|
||||
const handleCreateTag = (name: string) => {
|
||||
let tag: Tag | undefined;
|
||||
realm.write(() => {
|
||||
tag = realm.create<Tag>(Tag.schema.name, {
|
||||
name,
|
||||
});
|
||||
});
|
||||
if (!tag) return;
|
||||
memeTags.set(tag.id.toHexString(), tag);
|
||||
setMemeTags(new Map(memeTags));
|
||||
setSearch(tag.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
visible={visible}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
padding: fixed.horizontalScale(10),
|
||||
borderTopLeftRadius: fixed.verticalScale(20),
|
||||
borderTopRightRadius: fixed.verticalScale(20),
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
styles.fullWidth,
|
||||
memeTagSearchModalStyles.modal,
|
||||
]}
|
||||
onDismiss={() => setVisible(false)}>
|
||||
<Searchbar
|
||||
placeholder="Search or Create Tags"
|
||||
onChangeText={handleSearch}
|
||||
value={search}
|
||||
style={{
|
||||
marginBottom: responsive.verticalScale(10),
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={tags}
|
||||
extraData={memeTags}
|
||||
keyExtractor={tag => tag.id.toHexString()}
|
||||
horizontal
|
||||
estimatedItemSize={120}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps={'always'}
|
||||
renderItem={({ item: tag }) => (
|
||||
<TagChip
|
||||
tag={tag}
|
||||
style={{
|
||||
marginRight: fixed.horizontalScale(8),
|
||||
}}
|
||||
onPress={() => handleTagPress(tag)}
|
||||
active={memeTags.has(tag.id.toHexString())}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<Chip
|
||||
icon="plus"
|
||||
mode="outlined"
|
||||
onPress={() => handleCreateTag(search.replaceAll(/\s+/g, ''))}>
|
||||
Create Tag #{search.replaceAll(/\s+/g, '')}
|
||||
</Chip>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeTagSearchModal;
|
70
src/components/memes/memeTagSelector.tsx
Normal file
70
src/components/memes/memeTagSelector.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { ComponentProps, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Chip } from 'react-native-paper';
|
||||
import { TagChip } from '../tags';
|
||||
import { Tag } from '../../database';
|
||||
import { useDimensions } from '../../contexts';
|
||||
import { MemeTagSearchModal } from '.';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
|
||||
const MemeTagSelector = ({
|
||||
memeTags,
|
||||
setMemeTags,
|
||||
...props
|
||||
}: {
|
||||
memeTags: Map<string, Tag>;
|
||||
setMemeTags: (tags: Map<string, Tag>) => void;
|
||||
} & ComponentProps<typeof View>) => {
|
||||
const { fixed, dimensions } = useDimensions();
|
||||
|
||||
const [tagSearchModalVisible, setTagSearchModalVisible] = useState(false);
|
||||
|
||||
const handleTagPress = (tag: Tag) => {
|
||||
const id = tag.id.toHexString();
|
||||
memeTags.delete(id) || memeTags.set(id, tag);
|
||||
setMemeTags(new Map(memeTags));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View {...props}>
|
||||
<FlashList
|
||||
data={[...memeTags.values()]}
|
||||
extraData={memeTags}
|
||||
keyExtractor={tag => tag.id.toHexString()}
|
||||
horizontal
|
||||
estimatedItemSize={120}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
renderItem={({ item: tag }) => (
|
||||
<TagChip
|
||||
tag={tag}
|
||||
onPress={() => handleTagPress(tag)}
|
||||
style={{
|
||||
marginRight: fixed.horizontalScale(4),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListFooterComponent={() => (
|
||||
<Chip
|
||||
icon="plus"
|
||||
mode="outlined"
|
||||
onPress={() => setTagSearchModalVisible(true)}
|
||||
style={{
|
||||
marginRight: dimensions.width * 0.92 - 105.8,
|
||||
}}>
|
||||
Add Tag
|
||||
</Chip>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<MemeTagSearchModal
|
||||
visible={tagSearchModalVisible}
|
||||
setVisible={setTagSearchModalVisible}
|
||||
memeTags={memeTags}
|
||||
setMemeTags={setMemeTags}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeTagSelector;
|
3
src/components/tags/index.ts
Normal file
3
src/components/tags/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as TagChip } from './tagChip';
|
||||
export { default as TagEditor } from './tagEditor';
|
||||
export { default as TagPreview } from './tagPreview';
|
@@ -1,24 +1,57 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getContrastColor } from '../../utilities';
|
||||
import { Chip } from 'react-native-paper';
|
||||
import { Chip, useTheme } from 'react-native-paper';
|
||||
import { Tag } from '../../database';
|
||||
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
const tagChipStyles = StyleSheet.create({
|
||||
chip: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const TagChip = ({
|
||||
tag,
|
||||
active = true,
|
||||
onPress,
|
||||
...props
|
||||
}: {
|
||||
tag: Tag;
|
||||
active?: boolean;
|
||||
onPress?: () => void;
|
||||
} & Omit<React.ComponentProps<typeof Chip>, 'children'>) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const chipTheme = useMemo(() => {
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
secondaryContainer: tag.color,
|
||||
outline: tag.color,
|
||||
},
|
||||
};
|
||||
}, [tag.color, theme]);
|
||||
|
||||
const TagChip = ({ tag }: { tag: Tag }) => {
|
||||
const contrastColor = getContrastColor(tag.color);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
{...props}
|
||||
icon={() => {
|
||||
return <FontAwesome5 name="tag" color={contrastColor} />;
|
||||
return (
|
||||
<FontAwesome5 name="tag" color={active ? contrastColor : tag.color} />
|
||||
);
|
||||
}}
|
||||
compact
|
||||
style={[
|
||||
{
|
||||
backgroundColor: tag.color,
|
||||
},
|
||||
]}
|
||||
textStyle={{ color: contrastColor }}>
|
||||
theme={chipTheme}
|
||||
mode={active ? 'flat' : 'outlined'}
|
||||
style={[tagChipStyles.chip, props.style]}
|
||||
textStyle={{
|
||||
color: active ? contrastColor : theme.colors.onBackground,
|
||||
}}
|
||||
onPress={onPress}>
|
||||
{'#' + tag.name}
|
||||
</Chip>
|
||||
);
|
||||
|
90
src/components/tags/tagEditor.tsx
Normal file
90
src/components/tags/tagEditor.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { HelperText, TextInput } from 'react-native-paper';
|
||||
import TagPreview from './tagPreview';
|
||||
import { generateRandomColor, isValidColor } from '../../utilities';
|
||||
|
||||
const TagEditor = ({
|
||||
tagName,
|
||||
setTagName,
|
||||
tagColor,
|
||||
setTagColor,
|
||||
validatedTagColor,
|
||||
setValidatedTagColor,
|
||||
tagNameError,
|
||||
setTagNameError,
|
||||
tagColorError,
|
||||
setTagColorError,
|
||||
}: {
|
||||
tagName: string;
|
||||
setTagName: (name: string) => void;
|
||||
tagColor: string;
|
||||
setTagColor: (color: string) => void;
|
||||
validatedTagColor: string;
|
||||
setValidatedTagColor: (color: string) => void;
|
||||
tagNameError: string | undefined;
|
||||
setTagNameError: (error: string | undefined) => void;
|
||||
tagColorError: string | undefined;
|
||||
setTagColorError: (error: string | undefined) => void;
|
||||
}) => {
|
||||
const handleTagNameChange = (name: string) => {
|
||||
setTagName(name);
|
||||
|
||||
if (name.length === 0) {
|
||||
setTagNameError('Tag name cannot be empty');
|
||||
} else if (name.includes(' ')) {
|
||||
setTagNameError('Tag name cannot contain spaces');
|
||||
} else {
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
setTagNameError(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagColorChange = (color: string) => {
|
||||
setTagColor(color);
|
||||
|
||||
if (isValidColor(color)) {
|
||||
setValidatedTagColor(color);
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
setTagColorError(undefined);
|
||||
} else {
|
||||
setTagColorError('Color must be a valid hex or rgb value');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagPreview name={tagName} color={validatedTagColor} />
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Name"
|
||||
value={tagName}
|
||||
onChangeText={handleTagNameChange}
|
||||
error={!!tagNameError}
|
||||
autoCapitalize="none"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
<HelperText type="error" visible={!!tagNameError}>
|
||||
{tagNameError}
|
||||
</HelperText>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Color"
|
||||
value={tagColor}
|
||||
onChangeText={handleTagColorChange}
|
||||
error={!!tagColorError}
|
||||
autoCorrect={false}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon="palette"
|
||||
onPress={() => handleTagColorChange(generateRandomColor())}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<HelperText type="error" visible={!!tagColorError}>
|
||||
{tagColorError}
|
||||
</HelperText>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagEditor;
|
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
|
||||
import { Chip } from 'react-native-paper';
|
||||
import { Chip, useTheme } from 'react-native-paper';
|
||||
import styles from '../../styles';
|
||||
import { useDimensions } from '../../contexts';
|
||||
import { getContrastColor } from '../../utilities';
|
||||
@@ -16,7 +16,19 @@ const tagPreviewStyles = StyleSheet.create({
|
||||
});
|
||||
|
||||
const TagPreview = ({ name, color }: { name: string; color: string }) => {
|
||||
const theme = useTheme();
|
||||
const { responsive } = useDimensions();
|
||||
|
||||
const chipTheme = useMemo(() => {
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
secondaryContainer: color,
|
||||
},
|
||||
};
|
||||
}, [theme, color]);
|
||||
|
||||
const contrastColor = getContrastColor(color);
|
||||
|
||||
return (
|
||||
@@ -33,12 +45,8 @@ const TagPreview = ({ name, color }: { name: string; color: string }) => {
|
||||
return <FontAwesome5 name="tag" size={14} color={contrastColor} />;
|
||||
}}
|
||||
elevated
|
||||
style={[
|
||||
tagPreviewStyles.chip,
|
||||
{
|
||||
backgroundColor: color,
|
||||
},
|
||||
]}
|
||||
style={tagPreviewStyles.chip}
|
||||
theme={chipTheme}
|
||||
textStyle={[tagPreviewStyles.text, { color: contrastColor }]}>
|
||||
{'#' + name}
|
||||
</Chip>
|
||||
|
Reference in New Issue
Block a user