feat: implement most of the app
This commit is contained in:
parent
76d6ccb9f1
commit
705068d795
17 changed files with 5115 additions and 146 deletions
47
App.tsx
47
App.tsx
|
@ -1,20 +1,39 @@
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
|
import Chapter from './src/pages/chapter';
|
||||||
|
import Comic from './src/pages/comic';
|
||||||
|
import ComicProfile from './src/pages/comicprofile';
|
||||||
|
import Home from './src/pages/home';
|
||||||
|
import { ComicProviderKey } from './src/provider';
|
||||||
|
import { NonLocalProviderKey } from './src/provider/local';
|
||||||
|
import Save from './src/pages/save';
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export type RootStackParamList = {
|
||||||
|
Home: undefined;
|
||||||
|
ComicProfile: { provider: ComicProviderKey; comicId: string; };
|
||||||
|
Chapter: { provider: ComicProviderKey; comicId: string; chapterId: string; };
|
||||||
|
Page: { provider: ComicProviderKey; comicId: string; pageId: string; };
|
||||||
|
Save: { provider: NonLocalProviderKey; comicId: string; };
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Text>Open up App.tsx to start working on your app!</Text>
|
<NavigationContainer>
|
||||||
<StatusBar style="auto" />
|
<Stack.Navigator>
|
||||||
</View>
|
<Stack.Screen name="Home" component={Home} />
|
||||||
|
<Stack.Screen name="ComicProfile" component={ComicProfile} />
|
||||||
|
<Stack.Screen name="Chapter" component={Chapter} />
|
||||||
|
<Stack.Screen name="Page" component={Comic} />
|
||||||
|
<Stack.Screen name="Save" component={Save} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
<StatusBar style='dark' />
|
||||||
|
</NavigationContainer>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
2
app.json
2
app.json
|
@ -5,7 +5,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "dark",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash.png",
|
"image": "./assets/splash.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
|
|
15
package.json
15
package.json
|
@ -9,12 +9,25 @@
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/webpack-config": "^0.17.0",
|
||||||
|
"@react-native-picker/picker": "2.4.2",
|
||||||
|
"@react-navigation/native": "^6.0.12",
|
||||||
|
"@react-navigation/native-stack": "^6.8.0",
|
||||||
|
"@types/react-native-vector-icons": "^6.4.12",
|
||||||
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"expo": "~46.0.9",
|
"expo": "~46.0.9",
|
||||||
|
"expo-file-system": "~14.1.0",
|
||||||
"expo-status-bar": "~1.4.0",
|
"expo-status-bar": "~1.4.0",
|
||||||
"react": "18.0.0",
|
"react": "18.0.0",
|
||||||
"react-dom": "18.0.0",
|
"react-dom": "18.0.0",
|
||||||
"react-native": "0.69.5",
|
"react-native": "0.69.5",
|
||||||
"react-native-web": "~0.18.7"
|
"react-native-gesture-handler": "~2.5.0",
|
||||||
|
"react-native-modalize": "^2.1.1",
|
||||||
|
"react-native-safe-area-context": "4.3.1",
|
||||||
|
"react-native-screens": "~3.15.0",
|
||||||
|
"react-native-vector-icons": "^9.2.0",
|
||||||
|
"react-native-web": "~0.18.7",
|
||||||
|
"react-query": "^3.39.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.12.9",
|
"@babel/core": "^7.12.9",
|
||||||
|
|
46
src/components/ChapterList.tsx
Normal file
46
src/components/ChapterList.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import { FC } from "react";
|
||||||
|
import { ActivityIndicator, FlatList, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { RootStackParamList } from "../../App";
|
||||||
|
import { SimpleChapter as ChapterData, ComicProviderKey, useComicChapters } from "../provider";
|
||||||
|
import Touchable from "./Touchable";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
provider: ComicProviderKey;
|
||||||
|
comicId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Chapter: FC<ChapterData & { onClick: (id: string) => void }> = (props) => {
|
||||||
|
return <Touchable onPress={() => props.onClick(props.id)}>
|
||||||
|
<View style={styles.chapter}>
|
||||||
|
<Text>{props.title}</Text>
|
||||||
|
</View>
|
||||||
|
</Touchable>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChapterList: FC<Props> = (props) => {
|
||||||
|
const { data, error, isLoading } = useComicChapters(props.provider, props.comicId);
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
function openChapter(chapterId: string) {
|
||||||
|
navigation.navigate('Chapter', {
|
||||||
|
...props,
|
||||||
|
chapterId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FlatList data={data} renderItem={({ item }) => <Chapter onClick={openChapter} {...item} />} keyExtractor={item => item.id} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChapterList;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
chapter: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
});
|
47
src/components/ComicCard.tsx
Normal file
47
src/components/ComicCard.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { FC } from "react";
|
||||||
|
import { Image, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { ComicMetadata } from "../provider";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
comic: ComicMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Text style={styles.subtitle}>{props.comic.shortDescription}</Text>
|
||||||
|
<Text style={styles.author}>By {props.comic.author}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComicCard;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
aboutRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignContent: 'center',
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
paddingLeft: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
},
|
||||||
|
});
|
10
src/components/Divider.tsx
Normal file
10
src/components/Divider.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export default function Divider() {
|
||||||
|
return <View style={{
|
||||||
|
borderColor: '#ccc',
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
width: '100%',
|
||||||
|
paddingVertical: 8,
|
||||||
|
}} />
|
||||||
|
}
|
12
src/components/Touchable.tsx
Normal file
12
src/components/Touchable.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { FC } from "react";
|
||||||
|
import { Platform, TouchableNativeFeedback, TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
|
||||||
|
const Touchable: FC<TouchableOpacityProps> = (props) => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
return <TouchableNativeFeedback {...props} />
|
||||||
|
} else {
|
||||||
|
return <TouchableOpacity {...props} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Touchable;
|
54
src/pages/chapter.tsx
Normal file
54
src/pages/chapter.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
import { FC } from "react";
|
||||||
|
import { ActivityIndicator, FlatList, Platform, StyleSheet, Text, TouchableNativeFeedback, TouchableOpacity, View } from "react-native";
|
||||||
|
import { RootStackParamList } from "../../App";
|
||||||
|
import Divider from "../components/Divider";
|
||||||
|
import Touchable from "../components/Touchable";
|
||||||
|
import { SimplePage, useComicChapter } from "../provider";
|
||||||
|
|
||||||
|
const Page: FC<SimplePage & { onClick: (id: string) => void }> = (props) => {
|
||||||
|
return <Touchable onPress={() => props.onClick(props.id)}>
|
||||||
|
<View style={styles.page}>
|
||||||
|
<Text>{props.title}</Text>
|
||||||
|
</View>
|
||||||
|
</Touchable>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chapter(props: Props) {
|
||||||
|
const { data, error, isLoading } = useComicChapter(props.route.params.provider, props.route.params.comicId, props.route.params.chapterId, props.navigation);
|
||||||
|
|
||||||
|
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 chapter :/</Text>;
|
||||||
|
|
||||||
|
function openPage(pageId: string) {
|
||||||
|
props.navigation.navigate('Page', {
|
||||||
|
...props.route.params,
|
||||||
|
pageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={styles.container}>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
{data.description}
|
||||||
|
</Text>
|
||||||
|
<Divider />
|
||||||
|
<FlatList data={data.pages} renderItem={({ item }) => <Page {...item} onClick={openPage} />} keyExtractor={item => item.id} />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'Chapter'>;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 16,
|
||||||
|
paddingLeft: 8,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
});
|
72
src/pages/comic.tsx
Normal file
72
src/pages/comic.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { RootStackParamList } from "../../App";
|
||||||
|
import Touchable from "../components/Touchable";
|
||||||
|
import { useComicPage } from "../provider";
|
||||||
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
function newPage(pageId?: string) {
|
||||||
|
if (!pageId) return;
|
||||||
|
props.navigation.pop();
|
||||||
|
props.navigation.push('Page', {
|
||||||
|
...props.route.params,
|
||||||
|
pageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ScrollView>
|
||||||
|
{data.imageSegments.map((item) => <Image style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: item.width / item.height,
|
||||||
|
}} source={{ uri: item.url }} resizeMode='center' key={item.url} />)}
|
||||||
|
|
||||||
|
<View style={styles.navigationRow}>
|
||||||
|
<Touchable onPress={() => newPage(data.previousPageId)} disabled={data.previousPageId === undefined}>
|
||||||
|
<View style={styles.button}>
|
||||||
|
<MaterialCommunityIcons size={32} name='arrow-left-circle' color={data.previousPageId === undefined ? '#aaa' : '#555'} />
|
||||||
|
<Text style={styles.buttonLabel}>
|
||||||
|
Previous
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Touchable>
|
||||||
|
<Touchable onPress={() => newPage(data.nextPageId)} disabled={data.nextPageId === undefined}>
|
||||||
|
<View style={styles.button}>
|
||||||
|
<MaterialCommunityIcons size={32} name='arrow-right-circle' color={data.nextPageId === undefined ? '#aaa' : '#555'} />
|
||||||
|
<Text style={styles.buttonLabel}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Touchable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'Page'>;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
navigationRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
// justifyContent: 'space-evenly',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
// padding: 4,
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
paddingTop: 4,
|
||||||
|
}
|
||||||
|
});
|
48
src/pages/comicprofile.tsx
Normal file
48
src/pages/comicprofile.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
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 MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { useComicMetadata } from "../provider";
|
||||||
|
import Divider from "../components/Divider";
|
||||||
|
import ChapterList from "../components/ChapterList";
|
||||||
|
|
||||||
|
export default function ComicProfile(props: Props) {
|
||||||
|
const comicQuery = useComicMetadata(props.route.params.provider, props.route.params.comicId, (data) => {
|
||||||
|
props.navigation.setOptions({
|
||||||
|
title: data.title,
|
||||||
|
headerRight: props.route.params.provider === 'local' ? undefined : () => (
|
||||||
|
<TouchableOpacity onPress={() => {
|
||||||
|
const { provider, comicId } = props.route.params;
|
||||||
|
if (provider !== 'local') {
|
||||||
|
props.navigation.push('Save', {
|
||||||
|
provider,
|
||||||
|
comicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<MaterialCommunityIcons name='plus-circle' size={24} style={{ color: 'black' }} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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} />
|
||||||
|
<Divider />
|
||||||
|
<ChapterList provider={props.route.params.provider} comicId={props.route.params.comicId} />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'ComicProfile'>;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
})
|
53
src/pages/home.tsx
Normal file
53
src/pages/home.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { Picker } from "@react-native-picker/picker";
|
||||||
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { useQueryClient } from "react-query";
|
||||||
|
import { RootStackParamList } from "../../App";
|
||||||
|
import { ComicProviderKey } from "../provider";
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
|
||||||
|
export default function Home(props: Props) {
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<ComicProviderKey>('comicfury');
|
||||||
|
const [comicId, setComicId] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return <View style={styles.container}>
|
||||||
|
<Text>Hello, World!</Text>
|
||||||
|
<Picker<ComicProviderKey> style={styles.input} selectedValue={selectedProvider} onValueChange={(v) => setSelectedProvider(v)}>
|
||||||
|
<Picker.Item value='comicfury' label='Comicfury' />
|
||||||
|
<Picker.Item value='local' label='Local' />
|
||||||
|
</Picker>
|
||||||
|
<TextInput style={styles.input} onChangeText={setComicId} value={comicId} placeholder='Comic ID' />
|
||||||
|
<View style={styles.button}>
|
||||||
|
<Button title="Load comic" onPress={() => props.navigation.navigate('ComicProfile', { provider: selectedProvider, comicId })} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.button}>
|
||||||
|
<Button title="Yeet cache" onPress={() => queryClient.getQueryCache().clear()} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.button}>
|
||||||
|
<Button title="Yeet library" onPress={async () => {
|
||||||
|
await FileSystem.deleteAsync(FileSystem.documentDirectory + '/comics', { idempotent: true });
|
||||||
|
alert('library yeeted!');
|
||||||
|
}} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
});
|
54
src/pages/save.tsx
Normal file
54
src/pages/save.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { RootStackParamList } from "../../App";
|
||||||
|
import localProvider from "../provider/local";
|
||||||
|
|
||||||
|
export default function Save(props: Props) {
|
||||||
|
const [newId, setNewId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<any | null>(null);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => props.navigation.addListener('beforeRemove', (e) => {
|
||||||
|
if (newId || error) return;
|
||||||
|
e.preventDefault();
|
||||||
|
}), [props.navigation, newId, error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (newId) {
|
||||||
|
props.navigation.pop(2);
|
||||||
|
props.navigation.push('ComicProfile', {
|
||||||
|
provider: 'local',
|
||||||
|
comicId: newId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [newId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localProvider.downloadComicData(props.route.params.provider, props.route.params.comicId, setMessage)
|
||||||
|
.then((newId) => {
|
||||||
|
if (newId) {
|
||||||
|
setNewId(newId);
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
setError(err);
|
||||||
|
setMessage('Failed: ' + err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <View style={styles.container}>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
<Text>Saving comic...</Text>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'Save'>;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
129
src/provider/comicfury.ts
Normal file
129
src/provider/comicfury.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import type { SimpleChapter, ComicProvider, SimplePage, ImageSegment } from ".";
|
||||||
|
import scrapePage from "../util/fetch";
|
||||||
|
|
||||||
|
const comicfuryProvider: ComicProvider = {
|
||||||
|
async getComicInfo(comicId) {
|
||||||
|
const baseUrl = `https://comicfury.com/comicprofile.php?url=${encodeURIComponent(comicId)}`;
|
||||||
|
const $ = await scrapePage(baseUrl);
|
||||||
|
if (!$) return null;
|
||||||
|
|
||||||
|
const title = $('.profile .profileinfobox .username-and-title .authorname').text().trim();
|
||||||
|
let avatarUrl = $('.profile-avatar img[alt="Webcomic avatar"]').attr('src') ?? '';
|
||||||
|
const shortDescription = $('.profile .profileinfobox .username-and-title em').text().trim();
|
||||||
|
|
||||||
|
let description = '';
|
||||||
|
let author = '';
|
||||||
|
let authorAvatarUrl = '';
|
||||||
|
|
||||||
|
$('.profilecategory').each(function () {
|
||||||
|
const header = $(this).find('.pchead').text().trim();
|
||||||
|
const content = $(this).find('.pccontent')
|
||||||
|
if (header === 'Webcomic description') {
|
||||||
|
content.find('.description-tags').remove(); // Remove the tags to ease fetching the description text.
|
||||||
|
description = content.text().trim();
|
||||||
|
} else if (header === 'Authors') {
|
||||||
|
authorAvatarUrl = content.find('.pcleft-user img').attr('src') ?? '';
|
||||||
|
author = content.find('.authorname').text().trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (avatarUrl !== '') {
|
||||||
|
avatarUrl = new URL(avatarUrl, 'https://comicfury.com').toString();
|
||||||
|
}
|
||||||
|
if (authorAvatarUrl !== '') {
|
||||||
|
authorAvatarUrl = new URL(authorAvatarUrl, 'https://comicfury.com').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
avatarUrl,
|
||||||
|
author,
|
||||||
|
authorAvatarUrl,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listChapters(comicId) {
|
||||||
|
const baseUrl = `https://comicfury.com/read/${encodeURIComponent(comicId)}/archive`;
|
||||||
|
const $ = await scrapePage(baseUrl);
|
||||||
|
if (!$) return null;
|
||||||
|
|
||||||
|
const chapters: SimpleChapter[] = [];
|
||||||
|
$('.archive-chapter').each(function () {
|
||||||
|
const url = $(this).parent().attr('href')!;
|
||||||
|
const id = url.substring(`/read/${encodeURIComponent(comicId)}/archive/chapter/`.length);
|
||||||
|
const title = $(this).text().trim();
|
||||||
|
chapters.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return chapters;
|
||||||
|
},
|
||||||
|
|
||||||
|
async chapterInfo(comicId, chapterId) {
|
||||||
|
const baseUrl = `https://comicfury.com/read/${encodeURIComponent(comicId)}/archive/chapter/${encodeURIComponent(chapterId)}`;
|
||||||
|
const $ = await scrapePage(baseUrl);
|
||||||
|
if (!$) return null;
|
||||||
|
|
||||||
|
const title = $('.webcomic-title-content-inner').text().trim();
|
||||||
|
const description = $('.webcomic-title-subtext').text().trim();
|
||||||
|
|
||||||
|
const pages: SimplePage[] = [];
|
||||||
|
$('.archive-comic').each(function () {
|
||||||
|
const url = $(this).parent().attr('href')!;
|
||||||
|
const id = url.substring(`/read/${encodeURIComponent(comicId)}/comics/`.length);
|
||||||
|
const title = $(this).find('.archive-comic-title').text().trim();
|
||||||
|
const published = $(this).find('.archive-comic-date').text().trim();
|
||||||
|
pages.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
published,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: chapterId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
pages,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPage(comicId, pageId) {
|
||||||
|
const baseUrl = `https://comicfury.com/read/${encodeURIComponent(comicId)}/comics/${encodeURIComponent(pageId)}`;
|
||||||
|
const $ = await scrapePage(baseUrl);
|
||||||
|
if (!$) return null;
|
||||||
|
|
||||||
|
const pageRoot = $(`.is--comic-page#comic-${pageId}`);
|
||||||
|
const title = pageRoot.find('.is--title').text().trim();
|
||||||
|
const published = pageRoot.find('.is--author-notes .is--comment-time').text().trim();
|
||||||
|
const nextPageId = pageRoot.attr('data-next-comicid');
|
||||||
|
const previousPageId = pageRoot.attr('data-prev-comicid');
|
||||||
|
const imageSegmentUrls: ImageSegment[] = [];
|
||||||
|
|
||||||
|
pageRoot.find('.is--image-segment img').each(function () {
|
||||||
|
const url = $(this).attr('src')!;
|
||||||
|
const width = parseInt($(this).attr('width')!);
|
||||||
|
const height = parseInt($(this).attr('height')!);
|
||||||
|
imageSegmentUrls.push({
|
||||||
|
url,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: pageId,
|
||||||
|
title,
|
||||||
|
published,
|
||||||
|
imageSegments: imageSegmentUrls,
|
||||||
|
previousPageId: previousPageId === '-1' ? undefined : previousPageId,
|
||||||
|
nextPageId: nextPageId === '-1' ? undefined : nextPageId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default comicfuryProvider;
|
114
src/provider/index.ts
Normal file
114
src/provider/index.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { RootStackParamList } from "../../App";
|
||||||
|
import comicfuryProvider from "./comicfury";
|
||||||
|
import localProvider from "./local";
|
||||||
|
|
||||||
|
export interface ComicProvider {
|
||||||
|
getComicInfo(comicId: string): Promise<ComicMetadata | null>;
|
||||||
|
listChapters(comicId: string): Promise<SimpleChapter[] | null>;
|
||||||
|
chapterInfo(comicId: string, chapterId: string): Promise<FullChapter | null>;
|
||||||
|
getPage(comicId: string, pageId: string): Promise<FullPage | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComicMetadata {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
authorAvatarUrl: string;
|
||||||
|
shortDescription: string;
|
||||||
|
description: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleChapter {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullChapter {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pages: SimplePage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimplePage {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
published: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullPage {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
published: string;
|
||||||
|
imageSegments: ImageSegment[];
|
||||||
|
previousPageId?: string;
|
||||||
|
nextPageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageSegment {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMIC_PROVIDERS = {
|
||||||
|
comicfury: comicfuryProvider,
|
||||||
|
local: localProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComicProviderKey = keyof typeof COMIC_PROVIDERS;
|
||||||
|
|
||||||
|
export function useComicMetadata(provider: ComicProviderKey, comicId: string, onLoaded?: (data: ComicMetadata) => void) {
|
||||||
|
return useQuery<ComicMetadata | null, Error>(
|
||||||
|
['comic', provider, comicId],
|
||||||
|
() => COMIC_PROVIDERS[provider].getComicInfo(comicId),
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
if (onLoaded && data) {
|
||||||
|
onLoaded(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComicChapters(provider: ComicProviderKey, comicId: string) {
|
||||||
|
return useQuery<SimpleChapter[] | null, Error>(
|
||||||
|
['comicChapters', provider, comicId],
|
||||||
|
() => COMIC_PROVIDERS[provider].listChapters(comicId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComicChapter(provider: ComicProviderKey, comicId: string, chapterId: string, navigation?: NativeStackNavigationProp<RootStackParamList>) {
|
||||||
|
return useQuery<FullChapter | null, Error>(
|
||||||
|
['comicChapter', provider, comicId, chapterId],
|
||||||
|
() => COMIC_PROVIDERS[provider].chapterInfo(comicId, chapterId),
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
if (navigation && data) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: data.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComicPage(provider: ComicProviderKey, comicId: string, pageId: string, navigation?: NativeStackNavigationProp<RootStackParamList>) {
|
||||||
|
return useQuery<FullPage | null, Error>(
|
||||||
|
['comicPage', provider, comicId, pageId],
|
||||||
|
() => COMIC_PROVIDERS[provider].getPage(comicId, pageId),
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
if (navigation && data) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: data.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
105
src/provider/local.ts
Normal file
105
src/provider/local.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { ComicProvider, ComicProviderKey, COMIC_PROVIDERS, FullPage } from ".";
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
|
||||||
|
export type NonLocalProviderKey = Exclude<ComicProviderKey, 'local'>;
|
||||||
|
type LocalComicId = `${NonLocalProviderKey}-${string}`;
|
||||||
|
|
||||||
|
type LocalProvider = ComicProvider & {
|
||||||
|
downloadComicData(providerKey: NonLocalProviderKey, comicId: string, statusCallback?: (message: string) => void): Promise<LocalComicId | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function dataPath(path: string): string {
|
||||||
|
return FileSystem.documentDirectory + '/' + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDataDirectory(path: string) {
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(dataPath(path));
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(dataPath(path), { intermediates: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseComicId(comicId: string): [providerKey: string, comicId: string] | null {
|
||||||
|
const pts = comicId.split('-', 2);
|
||||||
|
if (pts.length !== 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pts as [string, string]; // typescript inference bad.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
const info = await FileSystem.getInfoAsync(path);
|
||||||
|
return info.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function localJson<T>(comicId: string, path: string): Promise<T | null> {
|
||||||
|
const parsed = parseComicId(comicId);
|
||||||
|
if (parsed === null) return null;
|
||||||
|
const [providerKey, id] = parsed;
|
||||||
|
const pth = dataPath(`comics/${providerKey}/${id}/${path}`);
|
||||||
|
if (!pathExists(pth)) return null;
|
||||||
|
|
||||||
|
return JSON.parse(await FileSystem.readAsStringAsync(pth));
|
||||||
|
}
|
||||||
|
|
||||||
|
const localProvider: LocalProvider = {
|
||||||
|
getComicInfo(comicId) {
|
||||||
|
return localJson(comicId, 'comic.json');
|
||||||
|
},
|
||||||
|
listChapters(comicId) {
|
||||||
|
return localJson(comicId, 'chapters.json');
|
||||||
|
},
|
||||||
|
chapterInfo(comicId, chapterId) {
|
||||||
|
return localJson(comicId, `chapters/${chapterId}.json`);
|
||||||
|
},
|
||||||
|
getPage(comicId, pageId) {
|
||||||
|
return localJson(comicId, `pages/${pageId}.json`);
|
||||||
|
},
|
||||||
|
async downloadComicData(providerKey, comicId, statusCallback) {
|
||||||
|
const provider = COMIC_PROVIDERS[providerKey];
|
||||||
|
const info = provider.getComicInfo(comicId);
|
||||||
|
if (info === null) return null;
|
||||||
|
|
||||||
|
const comicPath = `comics/${providerKey}/${comicId}`;
|
||||||
|
await ensureDataDirectory(comicPath);
|
||||||
|
await ensureDataDirectory(`${comicPath}/chapters`);
|
||||||
|
await ensureDataDirectory(`${comicPath}/pages`);
|
||||||
|
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/comic.json`), JSON.stringify(info));
|
||||||
|
|
||||||
|
const chapters = await provider.listChapters(comicId);
|
||||||
|
if (chapters === null) throw new Error('invalid state');
|
||||||
|
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/chapters.json`), JSON.stringify(chapters));
|
||||||
|
|
||||||
|
let chapterIndex = 0;
|
||||||
|
for (const incompleteChapter of chapters) {
|
||||||
|
chapterIndex += 1;
|
||||||
|
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length}`);
|
||||||
|
const chapter = await provider.chapterInfo(comicId, incompleteChapter.id);
|
||||||
|
if (chapter === null) throw new Error('invalid state');
|
||||||
|
|
||||||
|
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/chapters/${chapter.id}.json`), JSON.stringify(chapter));
|
||||||
|
|
||||||
|
let completedPages = 0;
|
||||||
|
const pageData = (await Promise.all(chapter.pages.map(async (incompletePage) => {
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(dataPath(`${comicPath}/pages/${incompletePage.id}.json`));
|
||||||
|
if (fileInfo.exists) return null;
|
||||||
|
const page = await provider.getPage(comicId, incompletePage.id);
|
||||||
|
if (page === null) throw new Error('invalid state');
|
||||||
|
completedPages += 1;
|
||||||
|
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length} - ${completedPages}/${chapter.pages.length}`)
|
||||||
|
return page;
|
||||||
|
}))).filter(page => page !== null) as FullPage[]; // typescript can't infer this either somehow
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
for (const page of pageData) {
|
||||||
|
i += 1;
|
||||||
|
await FileSystem.writeAsStringAsync(dataPath(`${comicPath}/pages/${page.id}.json`), JSON.stringify(page));
|
||||||
|
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length} - Written ${i}/${pageData.length}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${providerKey}-${comicId}`;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default localProvider;
|
26
src/util/fetch.ts
Normal file
26
src/util/fetch.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { CheerioAPI, load as cheerioLoad } from 'cheerio';
|
||||||
|
|
||||||
|
export default async function scrapePage(url: string): Promise<CheerioAPI | null> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'comet/1.0 (+https://git.ashhhleyyy.dev/ash/comet/)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
return res.text()
|
||||||
|
.then(html => cheerioLoad(html))
|
||||||
|
.then(($) => {
|
||||||
|
if ($('.errorbox').length !== 0) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return $;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (res.status === 404) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new Error(`request failed with code: ${res.status} (${res.statusText})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue