feat: Save last page and add library view for comics
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
0edd8778a3
commit
821f8331e5
11 changed files with 238 additions and 23 deletions
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@expo/vector-icons": "^13.0.0",
|
||||
"@expo/webpack-config": "^0.17.0",
|
||||
"@react-native-async-storage/async-storage": "~1.17.3",
|
||||
"@react-native-picker/picker": "2.4.2",
|
||||
"@react-navigation/bottom-tabs": "^6.4.0",
|
||||
"@react-navigation/native": "^6.0.12",
|
||||
|
|
22
src/components/Chip.tsx
Normal file
22
src/components/Chip.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
colour: string;
|
||||
children: ReactNode,
|
||||
}
|
||||
|
||||
const Chip: FC<Props> = (props) => {
|
||||
return <Text style={{
|
||||
backgroundColor: props.colour,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
height: 24,
|
||||
color: 'white',
|
||||
}}>
|
||||
{props.children}
|
||||
</Text>
|
||||
};
|
||||
|
||||
export default Chip;
|
|
@ -1,16 +1,27 @@
|
|||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { FC } from "react";
|
||||
import { Image, StyleSheet, Text, View } from "react-native";
|
||||
import { ComicMetadata } from "../provider";
|
||||
import Chip from "./Chip";
|
||||
|
||||
interface Props {
|
||||
comic: ComicMetadata
|
||||
comic: ComicMetadata & { isLocal?: boolean }
|
||||
}
|
||||
|
||||
const ComicCard: FC<Props> = (props) => {
|
||||
return <View style={styles.aboutRow}>
|
||||
<Image source={{ uri: props.comic.avatarUrl }} style={styles.avatar} />
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>{props.comic.title}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title}>
|
||||
{props.comic.title}
|
||||
</Text>
|
||||
{props.comic.isLocal && <View style={styles.localChip}>
|
||||
<Chip colour="#d602ee">
|
||||
<MaterialCommunityIcons name="download" size={16} />
|
||||
</Chip>
|
||||
</View>}
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{props.comic.shortDescription}</Text>
|
||||
<Text style={styles.author}>By {props.comic.author}</Text>
|
||||
</View>
|
||||
|
@ -29,9 +40,16 @@ const styles = StyleSheet.create({
|
|||
flexDirection: 'column',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
},
|
||||
localChip: {
|
||||
paddingLeft: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
},
|
||||
|
|
21
src/components/ComicSelector.tsx
Normal file
21
src/components/ComicSelector.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { FC } from "react"
|
||||
import { StyleSheet, View } from "react-native"
|
||||
import { ComicMetadata } from "../provider"
|
||||
import ComicCard from "./ComicCard"
|
||||
import Touchable from "./Touchable"
|
||||
|
||||
const ComicSelector: FC<ComicMetadata & { onClick: () => void; isLocal?: boolean }> = (props) => {
|
||||
return <Touchable onPress={() => props.onClick()}>
|
||||
<View style={styles.comic}>
|
||||
<ComicCard comic={props} />
|
||||
</View>
|
||||
</Touchable>
|
||||
}
|
||||
|
||||
export default ComicSelector;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
comic: {
|
||||
padding: 8,
|
||||
},
|
||||
});
|
|
@ -1,8 +1,11 @@
|
|||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, Image as NativeImage, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { createImageProgress } from "react-native-image-progress";
|
||||
import { Pie } from 'react-native-progress';
|
||||
import { useQueryClient } from "react-query";
|
||||
import { RootStackParamList } from "../../App";
|
||||
import Touchable from "../components/Touchable";
|
||||
import { useComicPage } from "../provider";
|
||||
|
@ -12,6 +15,15 @@ const Image = createImageProgress(NativeImage);
|
|||
export default function Comic(props: Props) {
|
||||
const { data, error, isLoading } = useComicPage(props.route.params.provider, props.route.params.comicId, props.route.params.pageId, props.navigation);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
AsyncStorage.setItem(`comic:${props.route.params.provider}:${props.route.params.comicId}:lastPageRead`, data.id);
|
||||
queryClient.invalidateQueries(['currentPage', props.route.params.provider, props.route.params.comicId])
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <ActivityIndicator />;
|
||||
if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
|
||||
if (!data) return <Text>Whoops! We couldn't find that page :/</Text>;
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { MaterialCommunityIcons, Ionicons } from "@expo/vector-icons";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import Library from "./comiclist/Library";
|
||||
import LocalComicList from "./comiclist/Local";
|
||||
|
||||
const Tab = createBottomTabNavigator<TabParamList>();
|
||||
|
||||
export type TabParamList = {
|
||||
Local: undefined;
|
||||
Library: undefined;
|
||||
Remote: undefined;
|
||||
};
|
||||
|
||||
|
@ -24,14 +26,14 @@ export default function ComicList() {
|
|||
screenOptions={({ route }) => {
|
||||
return {
|
||||
tabBarIcon: (({ focused, size, color }) => {
|
||||
const iconName = route.name === 'Local'
|
||||
? (focused ? 'download' : 'download-outline')
|
||||
: (focused ? 'cloud' : 'cloud-outline');
|
||||
return <MaterialCommunityIcons name={iconName} size={size} color={color} />
|
||||
if (route.name === 'Local') return <MaterialCommunityIcons name={focused ? 'download' : 'download-outline'} size={size} color={color} />
|
||||
if (route.name === 'Remote') return <MaterialCommunityIcons name={focused ? 'cloud' : 'cloud-outline'} size={size} color={color} />
|
||||
if (route.name === 'Library') return <Ionicons name={focused ? 'library' : 'library-outline'} size={size} color={color} />
|
||||
})
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="Library" component={Library} />
|
||||
<Tab.Screen name="Local" component={LocalComicList} />
|
||||
<Tab.Screen name="Remote" component={Remote} />
|
||||
</Tab.Navigator>
|
||||
|
|
80
src/pages/comiclist/Library.tsx
Normal file
80
src/pages/comiclist/Library.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
|
||||
import { CompositeNavigationProp, useNavigation } from "@react-navigation/native";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useQueryClient } from "react-query";
|
||||
import { RootStackParamList } from "../../../App";
|
||||
import ComicSelector from "../../components/ComicSelector";
|
||||
import { ComicProviderKey, useLibraryComics } from "../../provider";
|
||||
import { TabParamList } from "../comiclist";
|
||||
|
||||
export type LibraryNavigationProp = CompositeNavigationProp<
|
||||
BottomTabNavigationProp<TabParamList, 'Local'>,
|
||||
NativeStackNavigationProp<RootStackParamList, 'Home'>
|
||||
>;
|
||||
|
||||
export default function Library() {
|
||||
const client = useQueryClient();
|
||||
const navigator = useNavigation<LibraryNavigationProp>();
|
||||
const { data, error, isLoading } = useLibraryComics();
|
||||
|
||||
useEffect(() => {
|
||||
navigator.setOptions({
|
||||
headerRight: () => <View style={styles.iconRow}>
|
||||
<View style={styles.padding}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
client.invalidateQueries('libraryComics');
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons name="refresh" size={24} style={{ color: "black" }} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
if (isLoading) return <ActivityIndicator />;
|
||||
if (error) return <Text>Failed to load comics: {error.toString()}</Text>;
|
||||
if (!data) return <Text>Whoops! We couldn't load those comics :/</Text>;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <View style={styles.center}>
|
||||
<MaterialIcons name='sentiment-dissatisfied' size={32} color='#666' />
|
||||
<Text style={styles.noComicsText}>You aren't reading any comics</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
function openComic(provider: ComicProviderKey, id: string) {
|
||||
navigator.navigate('ComicProfile', {
|
||||
provider,
|
||||
comicId: id,
|
||||
});
|
||||
}
|
||||
|
||||
return <FlatList data={data} renderItem={({ item }) =>
|
||||
<ComicSelector onClick={() => openComic(item.provider, item.comicId)} isLocal={item.provider === 'local'} {...item} />
|
||||
} keyExtractor={item => item.provider + ':' + item.comicId} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
noComicsText: {
|
||||
color: '#666',
|
||||
},
|
||||
comic: {
|
||||
padding: 8,
|
||||
},
|
||||
iconRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
padding: {
|
||||
paddingEnd: 16,
|
||||
},
|
||||
});
|
|
@ -11,20 +11,13 @@ import { RootStackParamList } from "../../../App";
|
|||
import { LocalComicId } from "../../provider/local";
|
||||
import { useQueryClient } from "react-query";
|
||||
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
||||
import ComicSelector from "../../components/ComicSelector";
|
||||
|
||||
export type ComicListNavigationProp = CompositeNavigationProp<
|
||||
BottomTabNavigationProp<TabParamList, 'Local'>,
|
||||
NativeStackNavigationProp<RootStackParamList, 'Home'>
|
||||
>;
|
||||
|
||||
const Comic: FC<ComicMetadata & { onClick: () => void }> = (props) => {
|
||||
return <Touchable onPress={() => props.onClick()}>
|
||||
<View style={styles.comic}>
|
||||
<ComicCard comic={props} />
|
||||
</View>
|
||||
</Touchable>
|
||||
}
|
||||
|
||||
export default function LocalComicList() {
|
||||
const client = useQueryClient();
|
||||
const navigator = useNavigation<ComicListNavigationProp>();
|
||||
|
@ -57,8 +50,8 @@ export default function LocalComicList() {
|
|||
}, [client]);
|
||||
|
||||
if (isLoading) return <ActivityIndicator />;
|
||||
if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
|
||||
if (!data) return <Text>Whoops! We couldn't find that comic :/</Text>;
|
||||
if (error) return <Text>Failed to load comics: {error.toString()}</Text>;
|
||||
if (!data) return <Text>Whoops! We couldn't those comics :/</Text>;
|
||||
|
||||
function openComic(comicId: LocalComicId) {
|
||||
navigation.navigate('ComicProfile', {
|
||||
|
@ -74,7 +67,7 @@ export default function LocalComicList() {
|
|||
</View>
|
||||
}
|
||||
|
||||
return <FlatList data={data} renderItem={({ item }) => <Comic onClick={() => openComic(item.id)} {...item} />} keyExtractor={item => item.id} />
|
||||
return <FlatList data={data} renderItem={({ item }) => <ComicSelector onClick={() => openComic(item.id)} {...item} />} keyExtractor={item => item.id} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
@ -86,9 +79,6 @@ const styles = StyleSheet.create({
|
|||
noComicsText: {
|
||||
color: '#666',
|
||||
},
|
||||
comic: {
|
||||
padding: 8,
|
||||
},
|
||||
iconRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
|
|
@ -2,10 +2,11 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
|||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { RootStackParamList } from "../../App";
|
||||
import ComicCard from "../components/ComicCard";
|
||||
import { useComicMetadata } from "../provider";
|
||||
import { useComicMetadata, useCurrentPage } from "../provider";
|
||||
import Divider from "../components/Divider";
|
||||
import ChapterList from "../components/ChapterList";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import Touchable from "../components/Touchable";
|
||||
|
||||
export default function ComicProfile(props: Props) {
|
||||
const comicQuery = useComicMetadata(props.route.params.provider, props.route.params.comicId, (data) => {
|
||||
|
@ -27,12 +28,22 @@ export default function ComicProfile(props: Props) {
|
|||
});
|
||||
});
|
||||
|
||||
const { data: currentPage } = useCurrentPage(props.route.params.provider, props.route.params.comicId);
|
||||
|
||||
if (comicQuery.isLoading) return <ActivityIndicator />;
|
||||
if (comicQuery.error) return <Text>Failed to load comic: {comicQuery.error.toString()}</Text>;
|
||||
if (!comicQuery.data) return <Text>Whoops! We couldn't find that comic :/</Text>;
|
||||
|
||||
return <View style={styles.container}>
|
||||
<ComicCard comic={comicQuery.data} />
|
||||
{currentPage && <View>
|
||||
<Touchable onPress={() => props.navigation.push('Page', {
|
||||
...props.route.params,
|
||||
pageId: currentPage,
|
||||
})}>
|
||||
<MaterialCommunityIcons name="bookmark" size={24} color="red" />
|
||||
</Touchable>
|
||||
</View>}
|
||||
<Divider />
|
||||
<ChapterList provider={props.route.params.provider} comicId={props.route.params.comicId} />
|
||||
</View>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useQuery } from "react-query";
|
||||
import { RootStackParamList } from "../../App";
|
||||
|
@ -20,6 +21,8 @@ export interface ComicMetadata {
|
|||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export type FullComicMetadata = ComicMetadata & { comicId: string; provider: ComicProviderKey; };
|
||||
|
||||
export interface SimpleChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
|
@ -119,3 +122,39 @@ export function useLocalComics() {
|
|||
() => COMIC_PROVIDERS['local'].listComics(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Library comics are those that the user is currently reading and have a saved bookmark for
|
||||
*/
|
||||
export function useLibraryComics() {
|
||||
return useQuery<FullComicMetadata[], Error>(
|
||||
'libraryComics',
|
||||
async () => {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const promises: Promise<FullComicMetadata | null>[] = [];
|
||||
for (const key of keys) {
|
||||
const matches = /^comic:([a-z]+):([a-zA-Z0-9-_]+):lastPageRead$/.exec(key);
|
||||
if (!matches) continue;
|
||||
const provider = matches[1] as ComicProviderKey;
|
||||
const id = matches[2];
|
||||
promises.push(COMIC_PROVIDERS[provider].getComicInfo(id).then(comic => {
|
||||
if (!comic) return comic;
|
||||
return {
|
||||
...comic,
|
||||
comicId: id,
|
||||
provider,
|
||||
};
|
||||
}));
|
||||
}
|
||||
const comics = await Promise.all(promises);
|
||||
return comics.filter(comic => comic !== null) as FullComicMetadata[];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useCurrentPage(provider: string, comicId: string) {
|
||||
return useQuery<string | null, Error>(
|
||||
['currentPage', provider, comicId],
|
||||
() => AsyncStorage.getItem(`comic:${provider}:${comicId}:lastPageRead`)
|
||||
);
|
||||
}
|
||||
|
|
19
yarn.lock
19
yarn.lock
|
@ -1563,6 +1563,13 @@
|
|||
mkdirp "^1.0.4"
|
||||
rimraf "^3.0.2"
|
||||
|
||||
"@react-native-async-storage/async-storage@~1.17.3":
|
||||
version "1.17.10"
|
||||
resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.17.10.tgz#8d6a4771912be8454a9e215eebd469b1b8e2e638"
|
||||
integrity sha512-KrR021BmBLsA0TT1AAsfH16bHYy0MSbhdAeBAqpriak3GS1T2alFcdTUvn13p0ZW6FKRD6Bd3ryU2zhU/IYYJQ==
|
||||
dependencies:
|
||||
merge-options "^3.0.4"
|
||||
|
||||
"@react-native-community/cli-clean@^8.0.4":
|
||||
version "8.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-8.0.4.tgz#97e16a20e207b95de12e29b03816e8f2b2c80cc7"
|
||||
|
@ -5977,6 +5984,11 @@ is-path-inside@^3.0.2:
|
|||
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
||||
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
||||
|
||||
is-plain-obj@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
||||
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
|
||||
|
||||
is-plain-object@^2.0.3, is-plain-object@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
|
||||
|
@ -6653,6 +6665,13 @@ merge-descriptors@1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
|
||||
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==
|
||||
|
||||
merge-options@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7"
|
||||
integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==
|
||||
dependencies:
|
||||
is-plain-obj "^2.1.0"
|
||||
|
||||
merge-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
|
|
Loading…
Reference in a new issue