Reorganize files
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
@@ -15,17 +15,17 @@ import {
|
||||
ROUTE,
|
||||
RootStackParamList,
|
||||
StagingMeme,
|
||||
} from '../../types';
|
||||
import { Meme, Tag } from '../../database';
|
||||
import { RootState } from '../../state';
|
||||
} from '../../../types';
|
||||
import { Meme, Tag } from '../../../database';
|
||||
import { RootState } from '../../../state';
|
||||
import {
|
||||
allowedMimeTypes,
|
||||
getMemeTypeFromMimeType,
|
||||
guessMimeType,
|
||||
validateMemeTitle,
|
||||
} from '../../utilities';
|
||||
import { MemeEditor } from '../../components';
|
||||
import editorStyles from './editorStyles';
|
||||
} from '../../../utilities';
|
||||
import MemeEditor from './memeEditor';
|
||||
import editorStyles from '../editorStyles';
|
||||
|
||||
const AddMeme = ({
|
||||
route,
|
@@ -6,12 +6,12 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useObject, useRealm } from '@realm/react';
|
||||
import { useDeviceOrientation } from '@react-native-community/hooks';
|
||||
import { BSON } from 'realm';
|
||||
import { RootStackParamList, ROUTE, StagingMeme } from '../../types';
|
||||
import { pickSingle } from 'react-native-document-picker';
|
||||
import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
||||
import { extension } from 'react-native-mime-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Meme } from '../../database';
|
||||
import { RootStackParamList, ROUTE, StagingMeme } from '../../../types';
|
||||
import { Meme } from '../../../database';
|
||||
import {
|
||||
allowedMimeTypes,
|
||||
deleteMeme,
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
guessMimeType,
|
||||
noOp,
|
||||
validateMemeTitle,
|
||||
} from '../../utilities';
|
||||
import { MemeEditor } from '../../components';
|
||||
import editorStyles from './editorStyles';
|
||||
import { RootState } from '../../state';
|
||||
} from '../../../utilities';
|
||||
import { RootState } from '../../../state';
|
||||
import MemeEditor from './memeEditor';
|
||||
import editorStyles from '../editorStyles';
|
||||
|
||||
const EditMeme = ({
|
||||
route,
|
159
src/screens/editors/meme/memeEditor.tsx
Normal file
159
src/screens/editors/meme/memeEditor.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
|
||||
import { Image, LayoutAnimation } from 'react-native';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import Video from 'react-native-video';
|
||||
import { LoadingView, MemeFail } from '../../../components';
|
||||
import {
|
||||
getFilenameFromUri,
|
||||
getMemeTypeFromMimeType,
|
||||
validateMemeTitle,
|
||||
} from '../../../utilities';
|
||||
import { StagingMeme } from '../../../types';
|
||||
import { useMemeDimensions } from '../../../hooks';
|
||||
import { MEME_TYPE } from '../../../database';
|
||||
import MemeTagSelector from './memeTagSelector/memeTagSelector';
|
||||
|
||||
const memeEditorStyles = {
|
||||
media: {
|
||||
marginBottom: 15,
|
||||
borderRadius: 5,
|
||||
},
|
||||
uri: {
|
||||
marginBottom: 15,
|
||||
marginHorizontal: 5,
|
||||
},
|
||||
memeTagSelector: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
description: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const MemeEditor = ({
|
||||
uri,
|
||||
mimeType,
|
||||
loading,
|
||||
setLoading,
|
||||
error,
|
||||
setError,
|
||||
staging,
|
||||
setStaging,
|
||||
}: {
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
error: Error | undefined;
|
||||
setError: (error: Error | undefined) => void;
|
||||
staging?: StagingMeme;
|
||||
setStaging: (staging: StagingMeme) => void;
|
||||
}) => {
|
||||
const { width } = useSafeAreaFrame();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const { dimensions } = useMemeDimensions(
|
||||
uri,
|
||||
mimeType,
|
||||
useMemo(
|
||||
() => () => {
|
||||
setLoading(false);
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
},
|
||||
[setLoading],
|
||||
),
|
||||
useMemo(() => (errorIn: Error) => setError(errorIn), [setError]),
|
||||
);
|
||||
|
||||
const mediaComponent = useMemo(() => {
|
||||
if (!mimeType || !dimensions) return <></>;
|
||||
|
||||
const dimensionStyles = {
|
||||
width: width * 0.92,
|
||||
height: Math.max(
|
||||
Math.min((width * 0.92) / dimensions.aspectRatio, 500),
|
||||
100,
|
||||
),
|
||||
};
|
||||
|
||||
const memeType = getMemeTypeFromMimeType(mimeType);
|
||||
if (!memeType) return <></>;
|
||||
|
||||
switch (memeType) {
|
||||
case MEME_TYPE.IMAGE:
|
||||
case MEME_TYPE.GIF: {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={[memeEditorStyles.media, dimensionStyles]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case MEME_TYPE.VIDEO: {
|
||||
return (
|
||||
<Video
|
||||
source={{ uri }}
|
||||
style={[memeEditorStyles.media, dimensionStyles]}
|
||||
resizeMode="contain"
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}, [dimensions, mimeType, uri, width]);
|
||||
|
||||
if (!uri || !mimeType || !staging) return <LoadingView />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Title"
|
||||
value={staging.title.raw}
|
||||
onChangeText={title =>
|
||||
setStaging({ ...staging, title: validateMemeTitle(title) })
|
||||
}
|
||||
error={!staging.title.valid}
|
||||
selectTextOnFocus
|
||||
/>
|
||||
<HelperText type="error" visible={!staging.title.valid}>
|
||||
{staging.title.error}
|
||||
</HelperText>
|
||||
{error ? (
|
||||
<MemeFail
|
||||
style={[
|
||||
{
|
||||
width: width * 0.92,
|
||||
height: width * 0.92,
|
||||
},
|
||||
memeEditorStyles.media,
|
||||
]}
|
||||
iconSize={50}
|
||||
/>
|
||||
) : // eslint-disable-next-line unicorn/no-nested-ternary
|
||||
loading || !dimensions ? (
|
||||
<></>
|
||||
) : (
|
||||
mediaComponent
|
||||
)}
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]}
|
||||
numberOfLines={1}>
|
||||
{getFilenameFromUri(uri)}
|
||||
</Text>
|
||||
<MemeTagSelector
|
||||
memeTags={staging.tags}
|
||||
setMemeTags={tags => setStaging({ ...staging, tags })}
|
||||
style={memeEditorStyles.memeTagSelector}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeEditor;
|
168
src/screens/editors/meme/memeTagSelector/memeTagSearchModal.tsx
Normal file
168
src/screens/editors/meme/memeTagSelector/memeTagSearchModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useQuery, useRealm } from '@realm/react';
|
||||
import { Chip, Modal, Portal, Searchbar, useTheme } from 'react-native-paper';
|
||||
import { LayoutAnimation, StyleSheet } from 'react-native';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { TAG_SORT, tagSortQuery } from '../../../../types';
|
||||
import { TagChip } from '../../../../components';
|
||||
import { Tag } from '../../../../database';
|
||||
import { validateTagName } from '../../../../utilities';
|
||||
|
||||
const memeTagSearchModalStyles = StyleSheet.create({
|
||||
modal: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
padding: 10,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
width: '100%',
|
||||
},
|
||||
searchbar: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
tagChip: {
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const tagLayoutAnimation = {
|
||||
duration: 150,
|
||||
create: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
update: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
delete: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
};
|
||||
|
||||
const MemeTagSearchModal = ({
|
||||
visible,
|
||||
setVisible,
|
||||
memeTags,
|
||||
setMemeTags,
|
||||
}: {
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
memeTags: Map<string, Tag>;
|
||||
setMemeTags: (tags: Map<string, Tag>) => void;
|
||||
}) => {
|
||||
const { width } = useSafeAreaFrame();
|
||||
const { colors } = useTheme();
|
||||
const realm = useRealm();
|
||||
|
||||
const flashListRef = useRef<FlashList<Tag>>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [tagName, setTagName] = useState(validateTagName(search));
|
||||
|
||||
const handleSearch = (newSearch: string) => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0 });
|
||||
setSearch(newSearch);
|
||||
setTagName(validateTagName(newSearch));
|
||||
};
|
||||
|
||||
const tags = useQuery<Tag>(
|
||||
Tag.schema.name,
|
||||
collectionIn => {
|
||||
let collection = collectionIn;
|
||||
|
||||
if (search) {
|
||||
collection = collection.filtered('name CONTAINS[c] $0', tagName.parsed);
|
||||
}
|
||||
|
||||
collection = collection.sorted(
|
||||
tagSortQuery(TAG_SORT.DATE_MODIFIED),
|
||||
true,
|
||||
);
|
||||
|
||||
return collection;
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTagPress = (tag: Tag) => {
|
||||
const id = tag.id.toHexString();
|
||||
memeTags.delete(id) || memeTags.set(id, tag);
|
||||
setMemeTags(new Map(memeTags));
|
||||
flashListRef.current?.prepareForLayoutAnimationRender();
|
||||
LayoutAnimation.configureNext(tagLayoutAnimation);
|
||||
};
|
||||
|
||||
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));
|
||||
flashListRef.current?.prepareForLayoutAnimationRender();
|
||||
LayoutAnimation.configureNext(tagLayoutAnimation);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
visible={visible}
|
||||
contentContainerStyle={[
|
||||
memeTagSearchModalStyles.modal,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
onDismiss={() => setVisible(false)}>
|
||||
<Searchbar
|
||||
placeholder="Search or Create Tags"
|
||||
onChangeText={handleSearch}
|
||||
value={search}
|
||||
style={memeTagSearchModalStyles.searchbar}
|
||||
autoFocus
|
||||
/>
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={tags}
|
||||
extraData={memeTags}
|
||||
keyExtractor={tag => tag.id.toHexString()}
|
||||
horizontal
|
||||
estimatedItemSize={120}
|
||||
estimatedListSize={{
|
||||
width: width - 10,
|
||||
height: 34.5,
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps={'always'}
|
||||
renderItem={({ item: tag }) => (
|
||||
<TagChip
|
||||
tag={tag}
|
||||
style={memeTagSearchModalStyles.tagChip}
|
||||
onPress={() => handleTagPress(tag)}
|
||||
active={memeTags.has(tag.id.toHexString())}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<Chip
|
||||
icon="plus"
|
||||
mode="outlined"
|
||||
onPress={() =>
|
||||
handleCreateTag(tagName.valid ? tagName.parsed : 'newTag')
|
||||
}>
|
||||
Create Tag #{tagName.valid ? tagName.parsed : 'newTag'}
|
||||
</Chip>
|
||||
)}
|
||||
fadingEdgeLength={50}
|
||||
/>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeTagSearchModal;
|
101
src/screens/editors/meme/memeTagSelector/memeTagSelector.tsx
Normal file
101
src/screens/editors/meme/memeTagSelector/memeTagSelector.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { ComponentProps, useRef, useState } from 'react';
|
||||
import { LayoutAnimation, StyleSheet, View } from 'react-native';
|
||||
import { Chip } from 'react-native-paper';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { TagChip } from '../../../../components';
|
||||
import { Tag } from '../../../../database';
|
||||
import MemeTagSearchModal from './memeTagSearchModal';
|
||||
|
||||
const memeTagSelectorStyles = StyleSheet.create({
|
||||
tagChip: {
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const tagLayoutAnimation = {
|
||||
duration: 150,
|
||||
create: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
update: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
delete: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
};
|
||||
|
||||
const MemeTagSelector = ({
|
||||
memeTags,
|
||||
setMemeTags,
|
||||
...props
|
||||
}: {
|
||||
memeTags: Map<string, Tag>;
|
||||
setMemeTags: (tags: Map<string, Tag>) => void;
|
||||
} & ComponentProps<typeof View>) => {
|
||||
const { width } = useSafeAreaFrame();
|
||||
|
||||
const flashListRef = useRef<FlashList<Tag>>(null);
|
||||
|
||||
const [flashListMargin, setFlashListMargin] = useState(0);
|
||||
const [tagSearchModalVisible, setTagSearchModalVisible] = useState(false);
|
||||
|
||||
const handleTagPress = (tag: Tag) => {
|
||||
const id = tag.id.toHexString();
|
||||
memeTags.delete(id);
|
||||
setMemeTags(new Map(memeTags));
|
||||
flashListRef.current?.prepareForLayoutAnimationRender();
|
||||
LayoutAnimation.configureNext(tagLayoutAnimation);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View {...props}>
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={[...memeTags.values()]}
|
||||
keyExtractor={tag => tag.id.toHexString()}
|
||||
horizontal
|
||||
estimatedItemSize={120}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
renderItem={({ item: tag }) => (
|
||||
<TagChip
|
||||
tag={tag}
|
||||
onPress={() => handleTagPress(tag)}
|
||||
style={memeTagSelectorStyles.tagChip}
|
||||
/>
|
||||
)}
|
||||
ListFooterComponent={() => (
|
||||
<Chip
|
||||
icon="plus"
|
||||
mode="outlined"
|
||||
onPress={() => setTagSearchModalVisible(true)}
|
||||
onLayout={event =>
|
||||
setFlashListMargin(
|
||||
width * 0.92 - event.nativeEvent.layout.width,
|
||||
)
|
||||
}
|
||||
style={{
|
||||
marginRight: flashListMargin,
|
||||
}}>
|
||||
Add Tag
|
||||
</Chip>
|
||||
)}
|
||||
fadingEdgeLength={50}
|
||||
/>
|
||||
</View>
|
||||
<MemeTagSearchModal
|
||||
visible={tagSearchModalVisible}
|
||||
setVisible={setTagSearchModalVisible}
|
||||
memeTags={memeTags}
|
||||
setMemeTags={setMemeTags}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeTagSelector;
|
@@ -8,11 +8,11 @@ import {
|
||||
generateRandomColor,
|
||||
validateColor,
|
||||
validateTagName,
|
||||
} from '../../utilities';
|
||||
import { Tag } from '../../database';
|
||||
import { TagEditor } from '../../components';
|
||||
import editorStyles from './editorStyles';
|
||||
import { StagingTag } from '../../types';
|
||||
} from '../../../utilities';
|
||||
import { Tag } from '../../../database';
|
||||
import { StagingTag } from '../../../types';
|
||||
import TagEditor from './tagEditor';
|
||||
import editorStyles from '../editorStyles';
|
||||
|
||||
const AddTag = () => {
|
||||
const { goBack } = useNavigation();
|
@@ -6,11 +6,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { BSON } from 'realm';
|
||||
import { useObject, useRealm } from '@realm/react';
|
||||
import { useDeviceOrientation } from '@react-native-community/hooks';
|
||||
import { TagEditor } from '../../components';
|
||||
import { ROUTE, RootStackParamList, StagingTag } from '../../types';
|
||||
import { Tag } from '../../database';
|
||||
import { deleteTag, validateColor, validateTagName } from '../../utilities';
|
||||
import editorStyles from './editorStyles';
|
||||
import { ROUTE, RootStackParamList, StagingTag } from '../../../types';
|
||||
import { Tag } from '../../../database';
|
||||
import { deleteTag, validateColor, validateTagName } from '../../../utilities';
|
||||
import TagEditor from './tagEditor';
|
||||
import editorStyles from '../editorStyles';
|
||||
|
||||
const EditTag = ({
|
||||
route,
|
73
src/screens/editors/tag/tagEditor.tsx
Normal file
73
src/screens/editors/tag/tagEditor.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { HelperText, TextInput } from 'react-native-paper';
|
||||
import {
|
||||
generateRandomColor,
|
||||
validateColor,
|
||||
validateTagName,
|
||||
} from '../../../utilities';
|
||||
import { StagingTag } from '../../../types';
|
||||
import TagPreview from './tagPreview';
|
||||
|
||||
const TagEditor = ({
|
||||
staging,
|
||||
setStaging,
|
||||
}: {
|
||||
staging: StagingTag;
|
||||
setStaging: (staging: StagingTag) => void;
|
||||
}) => {
|
||||
const lastValidColor = useRef(staging.color.parsed);
|
||||
|
||||
useEffect(() => {
|
||||
if (staging.color.valid) lastValidColor.current = staging.color.parsed;
|
||||
}, [staging.color.parsed, staging.color.valid]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagPreview
|
||||
name={staging.name.parsed}
|
||||
color={
|
||||
staging.color.valid ? staging.color.parsed : lastValidColor.current
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Name"
|
||||
value={staging.name.raw}
|
||||
onChangeText={name =>
|
||||
setStaging({ ...staging, name: validateTagName(name) })
|
||||
}
|
||||
error={!staging.name.valid}
|
||||
selectTextOnFocus
|
||||
/>
|
||||
<HelperText type="error" visible={!staging.name.valid}>
|
||||
{staging.name.error}
|
||||
</HelperText>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label="Color"
|
||||
value={staging.color.raw}
|
||||
onChangeText={color =>
|
||||
setStaging({ ...staging, color: validateColor(color) })
|
||||
}
|
||||
error={!staging.color.valid}
|
||||
autoCorrect={false}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon="palette"
|
||||
onPress={() =>
|
||||
setStaging({
|
||||
...staging,
|
||||
color: validateColor(generateRandomColor()),
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<HelperText type="error" visible={!staging.color.valid}>
|
||||
{staging.color.error}
|
||||
</HelperText>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagEditor;
|
52
src/screens/editors/tag/tagPreview.tsx
Normal file
52
src/screens/editors/tag/tagPreview.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
|
||||
import { Chip, useTheme } from 'react-native-paper';
|
||||
import { getContrastColor } from '../../../utilities';
|
||||
|
||||
const tagPreviewStyles = StyleSheet.create({
|
||||
view: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
margin: '10%',
|
||||
},
|
||||
chip: {
|
||||
padding: 5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const TagPreview = ({ name, color }: { name: string; color: string }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const chipTheme = useMemo(() => {
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
secondaryContainer: color,
|
||||
},
|
||||
};
|
||||
}, [theme, color]);
|
||||
|
||||
const contrastColor = getContrastColor(color);
|
||||
|
||||
return (
|
||||
<View style={tagPreviewStyles.view}>
|
||||
<Chip
|
||||
icon={() => {
|
||||
return <FontAwesome5 name="tag" size={14} color={contrastColor} />;
|
||||
}}
|
||||
elevated
|
||||
style={tagPreviewStyles.chip}
|
||||
theme={chipTheme}
|
||||
textStyle={[tagPreviewStyles.text, { color: contrastColor }]}>
|
||||
{'#' + name}
|
||||
</Chip>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagPreview;
|
@@ -1,9 +1,9 @@
|
||||
export { default as AddMeme } from './editors/addMeme';
|
||||
export { default as AddTag } from './editors/addTag';
|
||||
export { default as EditMeme } from './editors/editMeme';
|
||||
export { default as EditTag } from './editors/editTag';
|
||||
export { default as Memes } from './memes';
|
||||
export { default as MemeView } from './memeView';
|
||||
export { default as Settings } from './settings';
|
||||
export { default as Tags } from './tags';
|
||||
export { default as AddMeme } from './editors/meme/addMeme';
|
||||
export { default as AddTag } from './editors/tag/addTag';
|
||||
export { default as EditMeme } from './editors/meme/editMeme';
|
||||
export { default as EditTag } from './editors/tag/editTag';
|
||||
export { default as Memes } from './memes/memes';
|
||||
export { default as MemeView } from './memeView/memeView';
|
||||
export { default as Settings } from './settings/settings';
|
||||
export { default as Tags } from './tags/tags';
|
||||
export { default as Welcome } from './welcome';
|
||||
|
@@ -12,9 +12,9 @@ import {
|
||||
RootStackParamList,
|
||||
ROUTE,
|
||||
SORT_DIRECTION,
|
||||
} from '../types';
|
||||
import { Meme } from '../database';
|
||||
import { LoadingView, MemeViewItem } from '../components';
|
||||
} from '../../types';
|
||||
import { Meme } from '../../database';
|
||||
import { LoadingView } from '../../components';
|
||||
import {
|
||||
copyMeme,
|
||||
deleteMeme,
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
favoriteMeme,
|
||||
multipleIdQuery,
|
||||
shareMeme,
|
||||
} from '../utilities';
|
||||
import { RootState } from '../state';
|
||||
} from '../../utilities';
|
||||
import { RootState } from '../../state';
|
||||
import MemeViewItem from './memeViewItem';
|
||||
|
||||
const memeViewStyles = StyleSheet.create({
|
||||
// eslint-disable-next-line react-native/no-color-literals
|
78
src/screens/memeView/memeViewItem.tsx
Normal file
78
src/screens/memeView/memeViewItem.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { AndroidScoped } from 'react-native-file-access';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Video from 'react-native-video';
|
||||
import { MEME_TYPE, Meme } from '../../database';
|
||||
import { RootState } from '../../state';
|
||||
import { AnimatedImage, LoadingView, MemeFail } from '../../components';
|
||||
import { useMemeDimensions } from '../../hooks';
|
||||
|
||||
const memeViewItemStyles = StyleSheet.create({
|
||||
view: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
const MemeViewItem = ({ meme }: { meme: Meme }) => {
|
||||
const { height, width } = useSafeAreaFrame();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const storageUri = useSelector(
|
||||
(state: RootState) => state.settings.storageUri,
|
||||
)!;
|
||||
|
||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||
|
||||
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||
|
||||
const mediaComponent = useMemo(() => {
|
||||
if (!dimensions) return <></>;
|
||||
|
||||
const dimensionStyles =
|
||||
dimensions.aspectRatio > width / (height - 128)
|
||||
? {
|
||||
width,
|
||||
height: width / (dimensions.width / dimensions.height),
|
||||
}
|
||||
: {
|
||||
width: (height - 128) * (dimensions.width / dimensions.height),
|
||||
height: height - 128,
|
||||
};
|
||||
|
||||
switch (meme.memeType) {
|
||||
case MEME_TYPE.IMAGE:
|
||||
case MEME_TYPE.GIF: {
|
||||
return <AnimatedImage source={{ uri }} style={dimensionStyles} />;
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<Video source={{ uri }} style={dimensionStyles} paused controls />
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [dimensions, height, meme.memeType, uri, width]);
|
||||
|
||||
if (!error && (loading || !dimensions)) {
|
||||
return <LoadingView style={{ width, height }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[{ width, height }, memeViewItemStyles.view]}>
|
||||
{error || !dimensions ? (
|
||||
<MemeFail
|
||||
style={{
|
||||
width: Math.min(width, height - 128),
|
||||
height: Math.min(width, height - 128),
|
||||
}}
|
||||
iconSize={50}
|
||||
/>
|
||||
) : (
|
||||
mediaComponent
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemeViewItem;
|
@@ -17,11 +17,13 @@ import {
|
||||
useNavigation,
|
||||
} from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../types';
|
||||
import { RootState, setNavVisible } from '../state';
|
||||
import { Meme } from '../database';
|
||||
import { HideableHeader, MemesHeader, MemesList } from '../components';
|
||||
import { useDeviceOrientation } from '@react-native-community/hooks';
|
||||
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../../types';
|
||||
import { RootState, setNavVisible } from '../../state';
|
||||
import { Meme } from '../../database';
|
||||
import { HideableHeader } from '../../components';
|
||||
import MemesHeader from './memesHeader';
|
||||
import MemesList from './memesList/memesList';
|
||||
|
||||
const memesStyles = StyleSheet.create({
|
||||
listView: {
|
174
src/screens/memes/memesHeader.tsx
Normal file
174
src/screens/memes/memesHeader.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { ComponentProps, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
Menu,
|
||||
Searchbar,
|
||||
useTheme,
|
||||
} from 'react-native-paper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { MEME_TYPE, memeTypePlural } from '../../database';
|
||||
import {
|
||||
RootState,
|
||||
cycleMemesView,
|
||||
setMemesFilter,
|
||||
setMemesSort,
|
||||
setMemesSortDirection,
|
||||
toggleMemesFavoritesOnly,
|
||||
toggleMemesSortDirection,
|
||||
} from '../../state';
|
||||
import { MEME_SORT, SORT_DIRECTION } from '../../types';
|
||||
import { getSortIcon, getViewIcon } from '../../utilities';
|
||||
|
||||
const memesHeaderStyles = StyleSheet.create({
|
||||
buttonView: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: 50,
|
||||
},
|
||||
buttonSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sortButton: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
});
|
||||
|
||||
const MemesHeader = ({
|
||||
search,
|
||||
setSearch,
|
||||
autoFocus,
|
||||
...props
|
||||
}: {
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
autoFocus: boolean;
|
||||
} & ComponentProps<typeof View>) => {
|
||||
const { colors } = useTheme();
|
||||
const sort = useSelector((state: RootState) => state.memes.sort);
|
||||
const sortDirection = useSelector(
|
||||
(state: RootState) => state.memes.sortDirection,
|
||||
);
|
||||
const view = useSelector((state: RootState) => state.memes.view);
|
||||
const favoritesOnly = useSelector(
|
||||
(state: RootState) => state.memes.favoritesOnly,
|
||||
);
|
||||
const filter = useSelector((state: RootState) => state.memes.filter);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [sortMenuVisible, setSortMenuVisible] = useState(false);
|
||||
const [filterMenuVisible, setFilterMenuVisible] = useState(false);
|
||||
|
||||
const handleSortModeChange = (newSort: MEME_SORT) => {
|
||||
if (newSort === sort) {
|
||||
dispatch(toggleMemesSortDirection());
|
||||
} else {
|
||||
dispatch(setMemesSort(newSort));
|
||||
if (newSort === MEME_SORT.TITLE) {
|
||||
dispatch(setMemesSortDirection(SORT_DIRECTION.ASCENDING));
|
||||
} else {
|
||||
dispatch(setMemesSortDirection(SORT_DIRECTION.DESCENDING));
|
||||
}
|
||||
}
|
||||
setSortMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilter: MEME_TYPE | undefined) => {
|
||||
dispatch(setMemesFilter(newFilter));
|
||||
setFilterMenuVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Searchbar
|
||||
placeholder="Search Memes"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<View style={memesHeaderStyles.buttonView}>
|
||||
<View style={memesHeaderStyles.buttonSection}>
|
||||
<Menu
|
||||
visible={sortMenuVisible}
|
||||
onDismiss={() => setSortMenuVisible(false)}
|
||||
anchor={
|
||||
<Button
|
||||
onPress={() => setSortMenuVisible(true)}
|
||||
icon={getSortIcon(sort, sortDirection)}
|
||||
contentStyle={memesHeaderStyles.sortButton}
|
||||
compact>
|
||||
Sort By: {sort}
|
||||
</Button>
|
||||
}>
|
||||
{Object.keys(MEME_SORT).map(key => {
|
||||
return (
|
||||
<Menu.Item
|
||||
key={key}
|
||||
onPress={() =>
|
||||
handleSortModeChange(
|
||||
MEME_SORT[key as keyof typeof MEME_SORT],
|
||||
)
|
||||
}
|
||||
title={MEME_SORT[key as keyof typeof MEME_SORT]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</View>
|
||||
<View style={memesHeaderStyles.buttonSection}>
|
||||
<IconButton
|
||||
icon={getViewIcon(view)}
|
||||
iconColor={colors.primary}
|
||||
size={16}
|
||||
animated
|
||||
onPress={() => dispatch(cycleMemesView())}
|
||||
/>
|
||||
<IconButton
|
||||
icon={favoritesOnly ? 'heart' : 'heart-outline'}
|
||||
iconColor={colors.primary}
|
||||
size={16}
|
||||
animated
|
||||
onPress={() => dispatch(toggleMemesFavoritesOnly())}
|
||||
/>
|
||||
<Menu
|
||||
visible={filterMenuVisible}
|
||||
onDismiss={() => setFilterMenuVisible(false)}
|
||||
anchor={
|
||||
<IconButton
|
||||
onPress={() => setFilterMenuVisible(true)}
|
||||
icon={filter ? 'filter' : 'filter-outline'}
|
||||
iconColor={colors.primary}
|
||||
size={16}
|
||||
/>
|
||||
}>
|
||||
<Menu.Item
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
onPress={() => handleFilterChange(undefined)}
|
||||
title="All"
|
||||
/>
|
||||
{Object.keys(MEME_TYPE).map(key => {
|
||||
return (
|
||||
<Menu.Item
|
||||
key={key}
|
||||
onPress={() =>
|
||||
handleFilterChange(MEME_TYPE[key as keyof typeof MEME_TYPE])
|
||||
}
|
||||
title={
|
||||
memeTypePlural[MEME_TYPE[key as keyof typeof MEME_TYPE]]
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</View>
|
||||
</View>
|
||||
<Divider />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemesHeader;
|
78
src/screens/memes/memesList/memesGridItem.tsx
Normal file
78
src/screens/memes/memesList/memesGridItem.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Image, TouchableHighlight } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { AndroidScoped } from 'react-native-file-access';
|
||||
import { MEME_TYPE, Meme } from '../../../database';
|
||||
import { RootState } from '../../../state';
|
||||
import { MemeFail } from '../../../components';
|
||||
import { getFontAwesome5IconSize } from '../../../utilities';
|
||||
import { useMemeDimensions } from '../../../hooks';
|
||||
|
||||
const MemesGridItem = ({
|
||||
meme,
|
||||
index,
|
||||
focusMeme,
|
||||
}: {
|
||||
meme: Meme;
|
||||
index: number;
|
||||
focusMeme: (index: number) => void;
|
||||
}) => {
|
||||
const { width } = useSafeAreaFrame();
|
||||
const gridColumns = useSelector(
|
||||
(state: RootState) => state.settings.gridColumns,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const storageUri = useSelector(
|
||||
(state: RootState) => state.settings.storageUri,
|
||||
)!;
|
||||
|
||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||
|
||||
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||
|
||||
const itemWidth = (width * 0.92 - 5) / gridColumns;
|
||||
|
||||
const mediaComponent = useMemo(() => {
|
||||
switch (meme.memeType) {
|
||||
case MEME_TYPE.IMAGE:
|
||||
case MEME_TYPE.GIF:
|
||||
case MEME_TYPE.VIDEO: {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={[
|
||||
{
|
||||
width: itemWidth,
|
||||
height: itemWidth,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}, [itemWidth, meme.memeType, uri]);
|
||||
|
||||
if (!error && (loading || !dimensions)) return <></>;
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={() => focusMeme(index)}>
|
||||
{error ? (
|
||||
<MemeFail
|
||||
style={{
|
||||
width: (width * 0.92 - 5) / gridColumns,
|
||||
height: (width * 0.92 - 5) / gridColumns,
|
||||
}}
|
||||
iconSize={getFontAwesome5IconSize(gridColumns)}
|
||||
/>
|
||||
) : (
|
||||
mediaComponent
|
||||
)}
|
||||
</TouchableHighlight>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemesGridItem;
|
162
src/screens/memes/memesList/memesList.tsx
Normal file
162
src/screens/memes/memesList/memesList.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { RefObject } from 'react';
|
||||
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
|
||||
import {
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Divider, HelperText } from 'react-native-paper';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { Meme } from '../../../database';
|
||||
import { RootState } from '../../../state';
|
||||
import { VIEW } from '../../../types';
|
||||
import { getFlashListItemHeight } from '../../../utilities';
|
||||
import MemesMasonryItem from './memesMasonryItem';
|
||||
import MemesGridItem from './memesGridItem';
|
||||
import MemesListItem from './memesListItem';
|
||||
|
||||
const sharedMemesListStyles = StyleSheet.create({
|
||||
flashList: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
helperText: {
|
||||
textAlign: 'center',
|
||||
marginVertical: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const memesMasonryListStyles = StyleSheet.create({
|
||||
flashList: {
|
||||
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876
|
||||
paddingHorizontal: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
const memesGridListStyles = StyleSheet.create({
|
||||
flashList: {
|
||||
paddingHorizontal: 2.5,
|
||||
},
|
||||
});
|
||||
|
||||
const memesListListStyles = StyleSheet.create({
|
||||
flashList: {
|
||||
paddingHorizontal: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const MemesList = ({
|
||||
memes,
|
||||
flashListRef,
|
||||
flashListPadding,
|
||||
handleScroll,
|
||||
focusMeme,
|
||||
}: {
|
||||
memes: Realm.Results<Meme & Realm.Object<Meme>>;
|
||||
flashListRef: RefObject<FlashList<Meme>>;
|
||||
flashListPadding: number;
|
||||
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
focusMeme: (index: number) => void;
|
||||
}) => {
|
||||
const { height, width } = useSafeAreaFrame();
|
||||
const view = useSelector((state: RootState) => state.memes.view);
|
||||
const masonryColumns = useSelector(
|
||||
(state: RootState) => state.settings.masonryColumns,
|
||||
);
|
||||
const gridColumns = useSelector(
|
||||
(state: RootState) => state.settings.gridColumns,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{view === VIEW.MASONRY && (
|
||||
<MasonryFlashList
|
||||
ref={flashListRef}
|
||||
data={memes}
|
||||
key={masonryColumns}
|
||||
estimatedItemSize={getFlashListItemHeight(masonryColumns)}
|
||||
estimatedListSize={{
|
||||
height,
|
||||
width: width * 0.92,
|
||||
}}
|
||||
numColumns={masonryColumns}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item: meme, index }) => (
|
||||
<MemesMasonryItem meme={meme} index={index} focusMeme={focusMeme} />
|
||||
)}
|
||||
contentContainerStyle={{
|
||||
paddingTop: flashListPadding,
|
||||
...sharedMemesListStyles.flashList,
|
||||
...memesMasonryListStyles.flashList,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<HelperText type={'info'} style={sharedMemesListStyles.helperText}>
|
||||
No memes found
|
||||
</HelperText>
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
fadingEdgeLength={100}
|
||||
/>
|
||||
)}
|
||||
{view === VIEW.GRID && (
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={memes}
|
||||
key={gridColumns}
|
||||
estimatedItemSize={getFlashListItemHeight(gridColumns)}
|
||||
estimatedListSize={{
|
||||
height: height,
|
||||
width: width * 0.92,
|
||||
}}
|
||||
numColumns={gridColumns}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item: meme, index }) => (
|
||||
<MemesGridItem meme={meme} index={index} focusMeme={focusMeme} />
|
||||
)}
|
||||
contentContainerStyle={{
|
||||
paddingTop: flashListPadding,
|
||||
...sharedMemesListStyles.flashList,
|
||||
...memesGridListStyles.flashList,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<HelperText type={'info'} style={sharedMemesListStyles.helperText}>
|
||||
No memes found
|
||||
</HelperText>
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
fadingEdgeLength={100}
|
||||
/>
|
||||
)}
|
||||
{view === VIEW.LIST && (
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={memes}
|
||||
estimatedItemSize={50}
|
||||
estimatedListSize={{
|
||||
height: height,
|
||||
width: width * 0.92,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item: meme, index }) => (
|
||||
<MemesListItem meme={meme} index={index} focusMeme={focusMeme} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <Divider />}
|
||||
contentContainerStyle={{
|
||||
paddingTop: flashListPadding,
|
||||
...sharedMemesListStyles.flashList,
|
||||
...memesListListStyles.flashList,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<HelperText type={'info'} style={sharedMemesListStyles.helperText}>
|
||||
No memes found
|
||||
</HelperText>
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
fadingEdgeLength={100}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemesList;
|
116
src/screens/memes/memesList/memesListItem.tsx
Normal file
116
src/screens/memes/memesList/memesListItem.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
import { Text, TouchableRipple } from 'react-native-paper';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { AndroidScoped } from 'react-native-file-access';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MEME_TYPE, Meme } from '../../../database';
|
||||
import { MemeFail } from '../../../components';
|
||||
import { RootState } from '../../../state';
|
||||
import { useMemeDimensions } from '../../../hooks';
|
||||
|
||||
const memesListItemStyles = StyleSheet.create({
|
||||
view: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
image: {
|
||||
height: 75,
|
||||
width: 75,
|
||||
borderRadius: 5,
|
||||
},
|
||||
detailsView: {
|
||||
flexDirection: 'column',
|
||||
marginLeft: 10,
|
||||
},
|
||||
text: {
|
||||
marginRight: 5,
|
||||
marginBottom: 5,
|
||||
},
|
||||
tagsView: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
});
|
||||
|
||||
const MemesListItem = ({
|
||||
meme,
|
||||
index,
|
||||
focusMeme,
|
||||
}: {
|
||||
meme: Meme;
|
||||
index: number;
|
||||
focusMeme: (index: number) => void;
|
||||
}) => {
|
||||
const { width } = useSafeAreaFrame();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const storageUri = useSelector(
|
||||
(state: RootState) => state.settings.storageUri,
|
||||
)!;
|
||||
|
||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||
|
||||
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||
|
||||
const mediaComponent = useMemo(() => {
|
||||
switch (meme.memeType) {
|
||||
case MEME_TYPE.IMAGE:
|
||||
case MEME_TYPE.GIF:
|
||||
case MEME_TYPE.VIDEO: {
|
||||
return <Image source={{ uri }} style={[memesListItemStyles.image]} />;
|
||||
}
|
||||
default: {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}, [meme.memeType, uri]);
|
||||
|
||||
if (!error && (loading || !dimensions)) return <></>;
|
||||
|
||||
return (
|
||||
<TouchableRipple
|
||||
onPress={() => focusMeme(index)}
|
||||
style={memesListItemStyles.view}>
|
||||
<>
|
||||
{error ? (
|
||||
<MemeFail style={memesListItemStyles.image} />
|
||||
) : (
|
||||
mediaComponent
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
memesListItemStyles.detailsView,
|
||||
{
|
||||
width: width * 0.92 - 75 - 10,
|
||||
},
|
||||
]}>
|
||||
<Text variant="titleMedium" style={memesListItemStyles.text}>
|
||||
{meme.title}
|
||||
</Text>
|
||||
<Text variant="labelSmall" style={memesListItemStyles.text}>
|
||||
{meme.dateModified.toLocaleDateString()} • {meme.size / 1000}
|
||||
KB
|
||||
</Text>
|
||||
<View style={memesListItemStyles.tagsView}>
|
||||
{meme.tags.map(tag => (
|
||||
<Text
|
||||
variant="labelMedium"
|
||||
key={tag.id.toHexString()}
|
||||
style={[
|
||||
{
|
||||
color: tag.color,
|
||||
},
|
||||
memesListItemStyles.text,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
#{tag.name}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</TouchableRipple>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemesListItem;
|
93
src/screens/memes/memesList/memesMasonryItem.tsx
Normal file
93
src/screens/memes/memesList/memesMasonryItem.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { AndroidScoped } from 'react-native-file-access';
|
||||
import { MEME_TYPE, Meme } from '../../../database';
|
||||
import { RootState } from '../../../state';
|
||||
import { MemeFail } from '../../../components';
|
||||
import { getFontAwesome5IconSize } from '../../../utilities';
|
||||
import { useMemeDimensions } from '../../../hooks';
|
||||
|
||||
const memeMasonryItemStyles = StyleSheet.create({
|
||||
view: {
|
||||
margin: 2.5,
|
||||
borderRadius: 5,
|
||||
},
|
||||
image: {
|
||||
borderRadius: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const MemesMasonryItem = ({
|
||||
meme,
|
||||
index,
|
||||
focusMeme,
|
||||
}: {
|
||||
meme: Meme;
|
||||
index: number;
|
||||
focusMeme: (index: number) => void;
|
||||
}) => {
|
||||
const { width } = useSafeAreaFrame();
|
||||
const masonryColumns = useSelector(
|
||||
(state: RootState) => state.settings.masonryColumns,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const storageUri = useSelector(
|
||||
(state: RootState) => state.settings.storageUri,
|
||||
)!;
|
||||
|
||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||
|
||||
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||
|
||||
const itemWidth = (width * 0.92 - 5) / masonryColumns - 5;
|
||||
const itemHeight =
|
||||
((width * 0.92) / masonryColumns - 5) / (dimensions?.aspectRatio ?? 1);
|
||||
|
||||
const mediaComponent = useMemo(() => {
|
||||
switch (meme.memeType) {
|
||||
case MEME_TYPE.IMAGE:
|
||||
case MEME_TYPE.GIF:
|
||||
case MEME_TYPE.VIDEO: {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={[
|
||||
memeMasonryItemStyles.image,
|
||||
{
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}, [itemHeight, itemWidth, meme.memeType, uri]);
|
||||
|
||||
if (!error && (loading || !dimensions)) return <></>;
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
onPress={() => focusMeme(index)}
|
||||
style={memeMasonryItemStyles.view}>
|
||||
{error || !dimensions ? (
|
||||
<MemeFail
|
||||
style={[
|
||||
memeMasonryItemStyles.image,
|
||||
{ width: itemWidth, height: itemHeight },
|
||||
]}
|
||||
iconSize={getFontAwesome5IconSize(masonryColumns)}
|
||||
/>
|
||||
) : (
|
||||
mediaComponent
|
||||
)}
|
||||
</TouchableHighlight>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemesMasonryItem;
|
@@ -11,6 +11,8 @@ import {
|
||||
} from 'react-native-paper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type {} from 'redux-thunk/extend-redux';
|
||||
import { useRealm } from '@realm/react';
|
||||
import { FileSystem, FileStat } from 'react-native-file-access';
|
||||
import {
|
||||
RootState,
|
||||
setAutofocusMemesSearch,
|
||||
@@ -19,11 +21,9 @@ import {
|
||||
setMasonryColumns,
|
||||
setNoMedia,
|
||||
setSnackbarMessage,
|
||||
} from '../state';
|
||||
import StorageLocationChangeDialog from '../components/storageLocationChangeDialog';
|
||||
import { useRealm } from '@realm/react';
|
||||
import { FileSystem, FileStat } from 'react-native-file-access';
|
||||
import { Meme } from '../database';
|
||||
} from '../../state';
|
||||
import { Meme } from '../../database';
|
||||
import StorageLocationChangeDialog from './storageLocationChangeDialog';
|
||||
|
||||
const settingsStyles = StyleSheet.create({
|
||||
scrollView: {
|
97
src/screens/settings/storageLocationChangeDialog.tsx
Normal file
97
src/screens/settings/storageLocationChangeDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Dialog, ProgressBar, Text } from 'react-native-paper';
|
||||
import { openDocumentTree } from 'react-native-scoped-storage';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
||||
import { RootState, setStorageUri } from '../../state';
|
||||
import { clearPermissions, isPermissionForPath, noOp } from '../../utilities';
|
||||
|
||||
const storageLocationChangeDialogStyles = StyleSheet.create({
|
||||
progressBar: {
|
||||
marginVertical: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const StorageLocationChangeDialog = ({
|
||||
visible,
|
||||
setVisible,
|
||||
setSnackbarMessage,
|
||||
}: {
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
setSnackbarMessage: (message: string) => void;
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const storageUri = useSelector(
|
||||
(state: RootState) => state.settings.storageUri,
|
||||
)!;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const selectNewStorageUri = async () => {
|
||||
const uri = await openDocumentTree(true).catch(noOp);
|
||||
|
||||
if (!uri) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newStorageUri = uri.uri;
|
||||
|
||||
if (isPermissionForPath(storageUri, newStorageUri)) {
|
||||
setSnackbarMessage('Folder already selected.');
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await FileSystem.ls(storageUri);
|
||||
let filesCopied = 0;
|
||||
|
||||
await Promise.all(
|
||||
files.map(async file => {
|
||||
const oldUri = AndroidScoped.appendPath(storageUri, file);
|
||||
const newUri = AndroidScoped.appendPath(newStorageUri, file);
|
||||
|
||||
// You may be wondering, why cp and unlink instead of mv?
|
||||
// That's because Android is a fuck and does not allow moving across different scoped storage paths.
|
||||
await FileSystem.cp(oldUri, newUri).catch(noOp);
|
||||
await FileSystem.unlink(oldUri).catch(noOp);
|
||||
|
||||
filesCopied++;
|
||||
setProgress(filesCopied / files.length);
|
||||
}),
|
||||
);
|
||||
|
||||
await dispatch(setStorageUri(newStorageUri));
|
||||
await clearPermissions([newStorageUri]);
|
||||
|
||||
setVisible(false);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
if (visible) void selectNewStorageUri();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onDismiss={() => setVisible(false)}
|
||||
dismissable={false}
|
||||
dismissableBackButton={false}>
|
||||
<Dialog.Title>Change Storage Location</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<Text>Copying files. Do not close the app.</Text>
|
||||
<ProgressBar
|
||||
animatedValue={progress}
|
||||
style={storageLocationChangeDialogStyles.progressBar}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageLocationChangeDialog;
|
@@ -13,10 +13,12 @@ import { FlashList } from '@shopify/flash-list';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useDeviceOrientation } from '@react-native-community/hooks';
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||
import { HideableHeader, TagRow, TagsHeader } from '../components';
|
||||
import { Tag } from '../database';
|
||||
import { RootState, setNavVisible } from '../state';
|
||||
import { SORT_DIRECTION, tagSortQuery } from '../types';
|
||||
import { HideableHeader } from '../../components';
|
||||
import { Tag } from '../../database';
|
||||
import { RootState, setNavVisible } from '../../state';
|
||||
import { SORT_DIRECTION, tagSortQuery } from '../../types';
|
||||
import TagsHeader from './tagsHeader';
|
||||
import TagRow from './tagsList/tagRow';
|
||||
|
||||
const tagsStyles = StyleSheet.create({
|
||||
listView: {
|
98
src/screens/tags/tagsHeader.tsx
Normal file
98
src/screens/tags/tagsHeader.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { ComponentProps, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Button, Divider, Menu, Searchbar } from 'react-native-paper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
RootState,
|
||||
setTagsSort,
|
||||
setTagsSortDirection,
|
||||
toggleTagsSortDirection,
|
||||
} from '../../state';
|
||||
import { SORT_DIRECTION, TAG_SORT } from '../../types';
|
||||
import { getSortIcon } from '../../utilities';
|
||||
|
||||
const tagsHeaderStyles = StyleSheet.create({
|
||||
buttonView: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: 50,
|
||||
},
|
||||
sortButton: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
});
|
||||
|
||||
const TagsHeader = ({
|
||||
search,
|
||||
setSearch,
|
||||
autoFocus,
|
||||
...props
|
||||
}: {
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
autoFocus: boolean;
|
||||
} & ComponentProps<typeof View>) => {
|
||||
const sort = useSelector((state: RootState) => state.tags.sort);
|
||||
const sortDirection = useSelector(
|
||||
(state: RootState) => state.tags.sortDirection,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [sortMenuVisible, setSortMenuVisible] = useState(false);
|
||||
|
||||
const handleSortModeChange = (newSort: TAG_SORT) => {
|
||||
if (newSort === sort) {
|
||||
dispatch(toggleTagsSortDirection());
|
||||
} else {
|
||||
dispatch(setTagsSort(newSort));
|
||||
if (newSort === TAG_SORT.NAME) {
|
||||
dispatch(setTagsSortDirection(SORT_DIRECTION.ASCENDING));
|
||||
} else {
|
||||
dispatch(setTagsSortDirection(SORT_DIRECTION.DESCENDING));
|
||||
}
|
||||
}
|
||||
setSortMenuVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Searchbar
|
||||
placeholder="Search Tags"
|
||||
value={search}
|
||||
onChangeText={(value: string) => {
|
||||
setSearch(value);
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<View style={tagsHeaderStyles.buttonView}>
|
||||
<Menu
|
||||
visible={sortMenuVisible}
|
||||
onDismiss={() => setSortMenuVisible(false)}
|
||||
anchor={
|
||||
<Button
|
||||
onPress={() => setSortMenuVisible(true)}
|
||||
icon={getSortIcon(sort, sortDirection)}
|
||||
contentStyle={tagsHeaderStyles.sortButton}
|
||||
compact>
|
||||
Sort By: {sort}
|
||||
</Button>
|
||||
}>
|
||||
{Object.keys(TAG_SORT).map(key => {
|
||||
return (
|
||||
<Menu.Item
|
||||
key={key}
|
||||
onPress={() =>
|
||||
handleSortModeChange(TAG_SORT[key as keyof typeof TAG_SORT])
|
||||
}
|
||||
title={TAG_SORT[key as keyof typeof TAG_SORT]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</View>
|
||||
<Divider />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsHeader;
|
37
src/screens/tags/tagsList/tagRow.tsx
Normal file
37
src/screens/tags/tagsList/tagRow.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { TouchableRipple, Text } from 'react-native-paper';
|
||||
import { useNavigation, NavigationProp } from '@react-navigation/native';
|
||||
import { Tag } from '../../../database';
|
||||
import { ROUTE, RootStackParamList } from '../../../types';
|
||||
import { TagChip } from '../../../components';
|
||||
|
||||
const tagRowStyles = StyleSheet.create({
|
||||
view: {
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 15,
|
||||
},
|
||||
tagChip: {
|
||||
flexShrink: 1,
|
||||
maxWidth: '80%',
|
||||
},
|
||||
});
|
||||
|
||||
const TagRow = ({ tag }: { tag: Tag }) => {
|
||||
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
return (
|
||||
<TouchableRipple
|
||||
onPress={() => navigate(ROUTE.EDIT_TAG, { id: tag.id.toHexString() })}>
|
||||
<View style={tagRowStyles.view}>
|
||||
<TagChip tag={tag} style={tagRowStyles.tagChip} />
|
||||
<Text>{tag.memesLength}</Text>
|
||||
</View>
|
||||
</TouchableRipple>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagRow;
|
Reference in New Issue
Block a user