inital
This commit is contained in:
35
App.tsx
35
App.tsx
@@ -1,28 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Sample React Native App
|
* PocketDog - React Native App
|
||||||
* https://github.com/facebook/react-native
|
* A modern article archiving app
|
||||||
*
|
|
||||||
* @format
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NewAppScreen } from '@react-native/new-app-screen';
|
import React from 'react';
|
||||||
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
|
import { StatusBar, useColorScheme, Platform } from 'react-native';
|
||||||
|
import AppNavigator from './src/navigation/AppNavigator';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<>
|
||||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
<StatusBar
|
||||||
<NewAppScreen templateFileName="App.tsx" />
|
barStyle={
|
||||||
</View>
|
Platform.OS === 'ios'
|
||||||
|
? 'dark-content'
|
||||||
|
: isDarkMode
|
||||||
|
? 'light-content'
|
||||||
|
: 'dark-content'
|
||||||
|
}
|
||||||
|
backgroundColor={Platform.OS === 'android' ? '#ffffff' : 'transparent'}
|
||||||
|
translucent={Platform.OS === 'android'}
|
||||||
|
/>
|
||||||
|
<AppNavigator />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
<!-- Customize your theme here. -->
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<!-- Enable edge-to-edge display -->
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<!-- Make status bar transparent -->
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Make navigation bar transparent -->
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Enable light status bar content -->
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,24 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
00929FE363A96A6E1098BB99 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
|
||||||
0C80B921A6F3F58F76C31292 /* libPods-PocketDog.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-PocketDog.a */; };
|
0C80B921A6F3F58F76C31292 /* libPods-PocketDog.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-PocketDog.a */; };
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
||||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
|
||||||
00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = 13B07F861A680F5B00A75B9A;
|
|
||||||
remoteInfo = PocketDog;
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
|
||||||
13B07F961A680F5B00A75B9A /* PocketDog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PocketDog.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
13B07F961A680F5B00A75B9A /* PocketDog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PocketDog.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = PocketDog/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = PocketDog/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = PocketDog/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = PocketDog/Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -49,14 +39,6 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
00E356F01AD99517003FC87E /* Supporting Files */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
00E356F11AD99517003FC87E /* Info.plist */,
|
|
||||||
);
|
|
||||||
name = "Supporting Files";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
13B07FAE1A68108700A75B9A /* PocketDog */ = {
|
13B07FAE1A68108700A75B9A /* PocketDog */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -172,19 +154,13 @@
|
|||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
00E356EC1AD99517003FC87E /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
13B07F8E1A680F5B00A75B9A /* Resources */ = {
|
13B07F8E1A680F5B00A75B9A /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
|
00929FE363A96A6E1098BB99 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -276,14 +252,6 @@
|
|||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
00E356F51AD99517003FC87E /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 13B07F861A680F5B00A75B9A /* PocketDog */;
|
|
||||||
targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -292,6 +260,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 8VJS8U8Z8Q;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = PocketDog/Info.plist;
|
INFOPLIST_FILE = PocketDog/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
@@ -320,6 +289,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 8VJS8U8Z8Q;
|
||||||
INFOPLIST_FILE = PocketDog/Info.plist;
|
INFOPLIST_FILE = PocketDog/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -408,7 +378,14 @@
|
|||||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||||
);
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
|
USE_HERMES = true;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -473,7 +450,13 @@
|
|||||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||||
);
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
USE_HERMES = true;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|||||||
7
ios/PocketDog.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/PocketDog.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
10
ios/PocketDog.xcworkspace/contents.xcworkspacedata
generated
Normal file
10
ios/PocketDog.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:PocketDog.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -29,6 +29,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
launchOptions: launchOptions
|
launchOptions: launchOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Configure status bar appearance
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
window?.overrideUserInterfaceStyle = .light
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 594 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
ios/PocketDog/Images.xcassets/AppIcon.appiconset/favicon.ico
Normal file
BIN
ios/PocketDog/Images.xcassets/AppIcon.appiconset/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -26,7 +26,6 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
@@ -34,6 +33,8 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string></string>
|
<string></string>
|
||||||
|
<key>RCTNewArchEnabled</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
@@ -48,5 +49,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIStatusBarStyle</key>
|
||||||
|
<string>UIStatusBarStyleDarkContent</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
2657
ios/Podfile.lock
Normal file
2657
ios/Podfile.lock
Normal file
File diff suppressed because it is too large
Load Diff
11454
package-lock.json
generated
11454
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,16 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-native/new-app-screen": "0.80.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.4.2",
|
||||||
|
"@react-navigation/native": "^7.1.14",
|
||||||
|
"@react-navigation/native-stack": "^7.3.21",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.80.0",
|
"react-native": "0.80.0",
|
||||||
"@react-native/new-app-screen": "0.80.0"
|
"react-native-safe-area-context": "^5.5.0",
|
||||||
|
"react-native-screens": "^4.11.1",
|
||||||
|
"react-native-webview": "^13.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|||||||
87
src/navigation/AppNavigator.tsx
Normal file
87
src/navigation/AppNavigator.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Text, Platform } from 'react-native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import {
|
||||||
|
SafeAreaProvider,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from 'react-native-safe-area-context';
|
||||||
|
import ArticlesScreen from '../screens/ArticlesScreen';
|
||||||
|
import ArticleViewScreen from '../screens/ArticleViewScreen';
|
||||||
|
import SettingsScreen from '../screens/SettingsScreen';
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
|
const ArticlesStack = () => {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="ArticlesList" component={ArticlesScreen} />
|
||||||
|
<Stack.Screen name="ArticleView" component={ArticleViewScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabNavigator: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: '#007bff',
|
||||||
|
tabBarInactiveTintColor: '#6c757d',
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#e9ecef',
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? insets.bottom : 8,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 8 : 8,
|
||||||
|
height: Platform.OS === 'ios' ? 60 + insets.bottom : 70,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: -2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: Platform.OS === 'android' ? 8 : 0,
|
||||||
|
},
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Articles"
|
||||||
|
component={ArticlesStack}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Articles',
|
||||||
|
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
||||||
|
<Text style={{ color, fontSize: size }}>📚</Text>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Settings"
|
||||||
|
component={SettingsScreen}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Settings',
|
||||||
|
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
||||||
|
<Text style={{ color, fontSize: size }}>⚙️</Text>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppNavigator: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<NavigationContainer>
|
||||||
|
<TabNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppNavigator;
|
||||||
318
src/screens/ArchiveScreen.tsx
Normal file
318
src/screens/ArchiveScreen.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
saveHtmlArticle,
|
||||||
|
saveLinkArticle,
|
||||||
|
validateHtmlUrl,
|
||||||
|
} from '../utils/articleUtils';
|
||||||
|
|
||||||
|
const ArchiveScreen: React.FC = () => {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [archiveAsHtml, setArchiveAsHtml] = useState(true);
|
||||||
|
|
||||||
|
const validateUrl = (urlString: string): boolean => {
|
||||||
|
try {
|
||||||
|
new URL(urlString);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
Alert.alert('Error', 'Please enter a URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateUrl(url.trim())) {
|
||||||
|
Alert.alert('Error', 'Please enter a valid URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let article;
|
||||||
|
|
||||||
|
if (archiveAsHtml) {
|
||||||
|
// Validate that the URL is accessible
|
||||||
|
const isAccessible = await validateHtmlUrl(url.trim());
|
||||||
|
if (!isAccessible) {
|
||||||
|
Alert.alert(
|
||||||
|
'Warning',
|
||||||
|
'The URL might not be accessible. Would you like to archive it as a link instead?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Archive as Link',
|
||||||
|
onPress: () => {
|
||||||
|
setArchiveAsHtml(false);
|
||||||
|
handleSubmit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive as HTML
|
||||||
|
article = await saveHtmlArticle(url.trim(), title.trim() || undefined);
|
||||||
|
Alert.alert('Success', 'HTML article archived successfully!', [
|
||||||
|
{ text: 'OK' },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Archive as link
|
||||||
|
article = await saveLinkArticle(url.trim(), title.trim() || undefined);
|
||||||
|
Alert.alert('Success', 'Link archived successfully!', [{ text: 'OK' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
setUrl('');
|
||||||
|
setTitle('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving article:', error);
|
||||||
|
Alert.alert('Error', `Failed to archive article: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = url.trim().length > 0 && validateUrl(url.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Archive Link</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
Save articles and links for later reading
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
<View style={styles.archiveTypeContainer}>
|
||||||
|
<Text style={styles.archiveTypeLabel}>Archive Type</Text>
|
||||||
|
<View style={styles.switchContainer}>
|
||||||
|
<Text style={styles.switchLabel}>
|
||||||
|
{archiveAsHtml ? '📄 HTML Content' : '🔗 Link Only'}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={archiveAsHtml}
|
||||||
|
onValueChange={setArchiveAsHtml}
|
||||||
|
trackColor={{ false: '#767577', true: '#81b0ff' }}
|
||||||
|
thumbColor={archiveAsHtml ? '#007bff' : '#f4f3f4'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.archiveTypeHelp}>
|
||||||
|
{archiveAsHtml
|
||||||
|
? 'Download and store the full HTML content for offline reading'
|
||||||
|
: 'Store only the link (faster, uses less storage)'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>URL *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
url.trim() && !validateUrl(url.trim()) && styles.inputError,
|
||||||
|
]}
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
{url.trim() && !validateUrl(url.trim()) && (
|
||||||
|
<Text style={styles.errorText}>Please enter a valid URL</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Title (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Article title"
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<Text style={styles.helpText}>
|
||||||
|
{archiveAsHtml
|
||||||
|
? 'Leave empty to auto-extract from the webpage'
|
||||||
|
: 'Leave empty to use "Untitled Article"'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.submitButton,
|
||||||
|
(!isFormValid || isSubmitting) && styles.submitButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={!isFormValid || isSubmitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitButtonText}>
|
||||||
|
{isSubmitting
|
||||||
|
? archiveAsHtml
|
||||||
|
? 'Downloading...'
|
||||||
|
: 'Archiving...'
|
||||||
|
: archiveAsHtml
|
||||||
|
? 'Archive as HTML'
|
||||||
|
: 'Archive as Link'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
scrollContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 20 : 24, // Extra padding for Android
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 16 : 20, // More padding for Android status bar
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e9ecef',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
archiveTypeContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e9ecef',
|
||||||
|
},
|
||||||
|
archiveTypeLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
switchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
switchLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#212529',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
archiveTypeHelp: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6c757d',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#dee2e6',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#212529',
|
||||||
|
// Android-specific input styling
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
inputError: {
|
||||||
|
borderColor: '#dc3545',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#dc3545',
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
helpText: {
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
// Android-specific button styling
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
submitButtonDisabled: {
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ArchiveScreen;
|
||||||
354
src/screens/ArticleViewScreen.tsx
Normal file
354
src/screens/ArticleViewScreen.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { WebView } from 'react-native-webview';
|
||||||
|
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
import { Article } from '../utils/articleUtils';
|
||||||
|
|
||||||
|
type RootStackParamList = {
|
||||||
|
ArticleView: { article: Article };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArticleViewRouteProp = RouteProp<RootStackParamList, 'ArticleView'>;
|
||||||
|
|
||||||
|
const ArticleViewScreen: React.FC = () => {
|
||||||
|
const route = useRoute<ArticleViewRouteProp>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { article } = route.params;
|
||||||
|
|
||||||
|
const handleOpenOriginal = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Open Original',
|
||||||
|
'Would you like to open the original URL in your browser?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Open',
|
||||||
|
onPress: () => {
|
||||||
|
// In a real app, you'd use Linking.openURL here
|
||||||
|
Alert.alert(
|
||||||
|
'Info',
|
||||||
|
'This would open the original URL in your browser',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Article',
|
||||||
|
'Are you sure you want to delete this article?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
// In a real app, you'd call deleteArticle here
|
||||||
|
navigation.goBack();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHtmlContent = () => {
|
||||||
|
if (article.type === 'html' && article.htmlContent) {
|
||||||
|
// Create a complete HTML document with our custom CSS
|
||||||
|
const htmlWithCSS = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #333333 !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
-webkit-text-size-adjust: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
margin-top: 24px !important;
|
||||||
|
margin-bottom: 12px !important;
|
||||||
|
color: #212529 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 24px !important; }
|
||||||
|
h2 { font-size: 20px !important; }
|
||||||
|
h3 { font-size: 18px !important; }
|
||||||
|
h4 { font-size: 16px !important; }
|
||||||
|
h5 { font-size: 14px !important; }
|
||||||
|
h6 { font-size: 12px !important; }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
text-align: justify !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
display: block !important;
|
||||||
|
margin: 16px auto !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 16px 0 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure img {
|
||||||
|
margin: 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: #6c757d !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
font-style: italic !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #007bff !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #007bff !important;
|
||||||
|
margin: 16px 0 !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
font-style: italic !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
padding: 2px 4px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
margin: 16px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background-color: transparent !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
padding-left: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100% !important;
|
||||||
|
border-collapse: collapse !important;
|
||||||
|
margin: 16px 0 !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #dee2e6 !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide any fixed positioned elements that might interfere */
|
||||||
|
.fixed, .sticky, [style*="position: fixed"], [style*="position: sticky"] {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any overlays or popups */
|
||||||
|
.modal, .overlay, .popup, .lightbox {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${article.htmlContent}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebView
|
||||||
|
source={{ html: htmlWithCSS }}
|
||||||
|
style={styles.webview}
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
domStorageEnabled={true}
|
||||||
|
startInLoadingState={true}
|
||||||
|
scalesPageToFit={false}
|
||||||
|
onError={(syntheticEvent: any) => {
|
||||||
|
const { nativeEvent } = syntheticEvent;
|
||||||
|
console.warn('WebView error: ', nativeEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<View style={styles.fallbackContainer}>
|
||||||
|
<Text style={styles.fallbackText}>
|
||||||
|
This article doesn't have HTML content available.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.openButton}
|
||||||
|
onPress={handleOpenOriginal}
|
||||||
|
>
|
||||||
|
<Text style={styles.openButtonText}>Open Original URL</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<Text style={styles.backButtonText}>← Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={2}>
|
||||||
|
{article.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
{new Date(article.archivedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuButton} onPress={handleDelete}>
|
||||||
|
<Text style={styles.menuButtonText}>⋮</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{renderHtmlContent()}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 8 : 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e9ecef',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#007bff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
menuButtonText: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
webview: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
fallbackContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
fallbackText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6c757d',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
openButton: {
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
openButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ArticleViewScreen;
|
||||||
445
src/screens/ArticlesScreen.tsx
Normal file
445
src/screens/ArticlesScreen.tsx
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
RefreshControl,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import {
|
||||||
|
Article,
|
||||||
|
loadArticles,
|
||||||
|
deleteArticle,
|
||||||
|
markArticleAsRead,
|
||||||
|
getReadStats,
|
||||||
|
} from '../utils/articleUtils';
|
||||||
|
|
||||||
|
const ArticlesScreen: React.FC = () => {
|
||||||
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [readStats, setReadStats] = useState({ total: 0, read: 0, unread: 0 });
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const loadArticlesData = async () => {
|
||||||
|
try {
|
||||||
|
const articlesData = await loadArticles();
|
||||||
|
setArticles(articlesData);
|
||||||
|
|
||||||
|
// Load read statistics
|
||||||
|
const stats = await getReadStats();
|
||||||
|
setReadStats(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading articles:', error);
|
||||||
|
Alert.alert('Error', 'Failed to load articles');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadArticlesData();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh articles when screen comes into focus (e.g., after returning from Settings)
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
loadArticlesData();
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadArticlesData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleArticlePress = async (article: Article) => {
|
||||||
|
// Mark article as read when opened
|
||||||
|
if (!article.isRead) {
|
||||||
|
try {
|
||||||
|
await markArticleAsRead(article.id);
|
||||||
|
// Update the local state to reflect the read status
|
||||||
|
setArticles(prevArticles =>
|
||||||
|
prevArticles.map(a =>
|
||||||
|
a.id === article.id
|
||||||
|
? { ...a, isRead: true, readAt: new Date().toISOString() }
|
||||||
|
: a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Update read stats
|
||||||
|
const stats = await getReadStats();
|
||||||
|
setReadStats(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking article as read:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article.type === 'html' && article.htmlContent) {
|
||||||
|
// Navigate to HTML article view
|
||||||
|
(navigation as any).navigate('ArticleView', { article });
|
||||||
|
} else {
|
||||||
|
// Show alert for regular links
|
||||||
|
Alert.alert('Article', `Opening: ${article.title}`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Open',
|
||||||
|
onPress: () => {
|
||||||
|
// In a real app, you'd use Linking.openURL here
|
||||||
|
Alert.alert('Info', 'This would open the URL in your browser');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArticle = async (articleId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Article',
|
||||||
|
'Are you sure you want to delete this article? This action cannot be undone.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteArticle(articleId);
|
||||||
|
await loadArticlesData(); // Refresh the list
|
||||||
|
Alert.alert('Success', 'Article deleted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting article:', error);
|
||||||
|
Alert.alert('Error', 'Failed to delete article');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAllArticles = async () => {
|
||||||
|
if (articles.length === 0) {
|
||||||
|
Alert.alert('No Articles', 'There are no articles to delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Delete All Articles',
|
||||||
|
`Are you sure you want to delete all ${articles.length} articles? This action cannot be undone.`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete All',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
// Clear all articles from storage
|
||||||
|
await AsyncStorage.removeItem('articles');
|
||||||
|
setArticles([]);
|
||||||
|
Alert.alert('Success', 'All articles deleted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting all articles:', error);
|
||||||
|
Alert.alert('Error', 'Failed to delete all articles');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderArticle = ({ item }: { item: Article }) => {
|
||||||
|
// Clean the title by removing newlines and excessive whitespace
|
||||||
|
const cleanTitle = item.title.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.articleCard, item.isRead && styles.articleCardRead]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.articleContent}
|
||||||
|
onPress={() => handleArticlePress(item)}
|
||||||
|
>
|
||||||
|
<View style={styles.articleHeader}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.articleTitle,
|
||||||
|
item.isRead && styles.articleTitleRead,
|
||||||
|
]}
|
||||||
|
numberOfLines={0}
|
||||||
|
>
|
||||||
|
{cleanTitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.articleUrl} numberOfLines={1}>
|
||||||
|
{item.url}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.articleDate}>
|
||||||
|
Archived: {new Date(item.archivedAt).toLocaleDateString()}
|
||||||
|
{item.timestamp && ` (${item.timestamp})`}
|
||||||
|
{item.isRead && item.readAt && (
|
||||||
|
<Text style={styles.readDate}>
|
||||||
|
{' • Read: '}
|
||||||
|
{new Date(item.readAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{item.type === 'html' && item.htmlContent && (
|
||||||
|
<Text style={styles.articleInfo}>Offline</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.menuButton}
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Article Options',
|
||||||
|
'What would you like to do with this article?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => handleDeleteArticle(item.id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.menuButtonText}>⋯</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerTop}>
|
||||||
|
<Text style={styles.headerTitle}>PocketDog</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.headerMenuButton}
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert('Menu', 'What would you like to do?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete All Articles',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: handleDeleteAllArticles,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.headerMenuButtonText}>⋯</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
{articles.length} article{articles.length !== 1 ? 's' : ''} archived
|
||||||
|
{readStats.total > 0 && (
|
||||||
|
<Text style={styles.readStats}>
|
||||||
|
{' • '}
|
||||||
|
{readStats.read} read, {readStats.unread} unread
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={articles}
|
||||||
|
renderItem={renderArticle}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
contentContainerStyle={styles.listContainer}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No articles yet</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
Use the Archive Manager in Settings to fetch articles
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 16 : 20, // More padding for Android status bar
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e9ecef',
|
||||||
|
},
|
||||||
|
headerTop: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6c757d',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 20 : 24, // Extra padding for Android
|
||||||
|
},
|
||||||
|
articleCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: Platform.OS === 'android' ? 5 : 0, // Android elevation
|
||||||
|
flexDirection: 'row', // Add this to position content and delete button side by side
|
||||||
|
alignItems: 'flex-start', // Align items to the top
|
||||||
|
},
|
||||||
|
articleCardRead: {
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
articleContent: {
|
||||||
|
flex: 1, // Take up most of the space
|
||||||
|
marginRight: 12, // Add space between content and menu button
|
||||||
|
},
|
||||||
|
articleHeader: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
articleTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#212529',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
articleTitleRead: {
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
articleType: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
articleTypeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
htmlType: {
|
||||||
|
color: '#28a745',
|
||||||
|
},
|
||||||
|
linkType: {
|
||||||
|
color: '#007bff',
|
||||||
|
},
|
||||||
|
articleUrl: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#007bff',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
articleDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6c757d',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
articleInfo: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#28a745',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
archiveSourceContainer: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
archiveSourceText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6c757d',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6c757d',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#adb5bd',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
readStats: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6c757d',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
readDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6c757d',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
menuButtonText: {
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
headerMenuButton: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
headerMenuButtonText: {
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ArticlesScreen;
|
||||||
482
src/screens/SettingsScreen.tsx
Normal file
482
src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { fetchArchiveResults } from '../utils/articleUtils';
|
||||||
|
|
||||||
|
const SettingsScreen: React.FC = () => {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [archiveResults, setArchiveResults] = useState<any>(null);
|
||||||
|
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const storedUrl = await AsyncStorage.getItem('settings_url');
|
||||||
|
const storedApiKey = await AsyncStorage.getItem('settings_api_key');
|
||||||
|
|
||||||
|
if (storedUrl) setUrl(storedUrl);
|
||||||
|
if (storedApiKey) setApiKey(storedApiKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateUrl = (urlString: string): boolean => {
|
||||||
|
if (!urlString.trim()) return true; // Allow empty URL
|
||||||
|
try {
|
||||||
|
new URL(urlString);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (url.trim() && !validateUrl(url.trim())) {
|
||||||
|
Alert.alert('Error', 'Please enter a valid URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem('settings_url', url.trim());
|
||||||
|
await AsyncStorage.setItem('settings_api_key', apiKey.trim());
|
||||||
|
|
||||||
|
Alert.alert('Success', 'Settings saved successfully!', [{ text: 'OK' }]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
Alert.alert('Error', 'Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Clear Settings',
|
||||||
|
'Are you sure you want to clear all settings?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Clear',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem('settings_url');
|
||||||
|
await AsyncStorage.removeItem('settings_api_key');
|
||||||
|
setUrl('');
|
||||||
|
setApiKey('');
|
||||||
|
Alert.alert('Success', 'Settings cleared successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing settings:', error);
|
||||||
|
Alert.alert('Error', 'Failed to clear settings');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchArchiveResults = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingResults(true);
|
||||||
|
|
||||||
|
if (!url || !apiKey) {
|
||||||
|
Alert.alert(
|
||||||
|
'Settings Required',
|
||||||
|
'Please configure the API URL and API Key first.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await fetchArchiveResults(url, apiKey);
|
||||||
|
setArchiveResults(results);
|
||||||
|
|
||||||
|
const downloadedCount = results.totalDownloaded || 0;
|
||||||
|
const totalCount = results.totalProcessed || 0;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Download Complete',
|
||||||
|
`Successfully downloaded ${downloadedCount} out of ${totalCount} articles from the archive.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching archive results:', error);
|
||||||
|
Alert.alert('Error', `Failed to fetch archive results: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderArchiveResults = () => {
|
||||||
|
if (!archiveResults) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.archiveResultsContainer}>
|
||||||
|
<Text style={styles.archiveResultsTitle}>
|
||||||
|
Archive Results ({archiveResults.totalProcessed || 0} processed,{' '}
|
||||||
|
{archiveResults.totalDownloaded || 0} downloaded)
|
||||||
|
</Text>
|
||||||
|
<ScrollView style={styles.archiveResultsScroll}>
|
||||||
|
{archiveResults.processedItems &&
|
||||||
|
archiveResults.processedItems.length > 0 ? (
|
||||||
|
archiveResults.processedItems.map((item: any, index: number) => (
|
||||||
|
<View key={index} style={styles.archiveItemContainer}>
|
||||||
|
<Text style={styles.archiveItemTitle}>
|
||||||
|
{index + 1}. {item.title || 'Untitled'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.archiveItemUrl}>
|
||||||
|
Original: {item.originalUrl || 'N/A'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.archiveItemDownloadUrl}>
|
||||||
|
Download: {item.downloadableUrl || 'N/A'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.archiveItemTimestamp}>
|
||||||
|
Timestamp: {item.timestamp || 'N/A'}
|
||||||
|
</Text>
|
||||||
|
{item.downloadError && (
|
||||||
|
<Text style={styles.archiveItemError}>
|
||||||
|
❌ Download failed: {item.downloadError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item.downloadableUrl && !item.downloadError && (
|
||||||
|
<Text style={styles.archiveItemSuccess}>
|
||||||
|
✅ Downloaded successfully
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={styles.archiveResultsText}>
|
||||||
|
{JSON.stringify(archiveResults, null, 2)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUrlValid = !url.trim() || validateUrl(url.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Settings</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
Configure your PocketDog preferences
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Service URL (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
url.trim() && !isUrlValid && styles.inputError,
|
||||||
|
]}
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
{url.trim() && !isUrlValid && (
|
||||||
|
<Text style={styles.errorText}>Please enter a valid URL</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.helpText}>
|
||||||
|
Leave empty to use default service
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>API Key (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Your API key"
|
||||||
|
value={apiKey}
|
||||||
|
onChangeText={setApiKey}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<Text style={styles.helpText}>Required for premium features</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.saveButton}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSaving || !isUrlValid}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.clearButton}
|
||||||
|
onPress={handleClear}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Text style={styles.clearButtonText}>Clear Settings</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.archiveSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Archive Manager</Text>
|
||||||
|
<Text style={styles.sectionSubtitle}>
|
||||||
|
Fetch and download articles from your archive service
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.fetchButton,
|
||||||
|
isLoadingResults && styles.fetchButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleFetchArchiveResults}
|
||||||
|
disabled={isLoadingResults}
|
||||||
|
>
|
||||||
|
<Text style={styles.fetchButtonText}>
|
||||||
|
{isLoadingResults
|
||||||
|
? 'Fetching...'
|
||||||
|
: '📡 Fetch Archive Results'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{renderArchiveResults()}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
scrollContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 20 : 24, // Extra padding for Android
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 16 : 20, // More padding for Android status bar
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e9ecef',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#dee2e6',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#212529',
|
||||||
|
// Android-specific input styling
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
inputError: {
|
||||||
|
borderColor: '#dc3545',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#dc3545',
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
helpText: {
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
// Android-specific button styling
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
// Android-specific button styling
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
clearButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
archiveSection: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e9ecef',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
sectionSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
fetchButton: {
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
// Android-specific button styling
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
fetchButtonDisabled: {
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
},
|
||||||
|
fetchButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
archiveResultsContainer: {
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e9ecef',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
archiveResultsTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
archiveResultsScroll: {
|
||||||
|
maxHeight: 200,
|
||||||
|
},
|
||||||
|
archiveItemContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
archiveItemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#212529',
|
||||||
|
},
|
||||||
|
archiveItemUrl: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
archiveItemDownloadUrl: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
archiveItemTimestamp: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
archiveItemError: {
|
||||||
|
color: '#dc3545',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
archiveItemSuccess: {
|
||||||
|
color: '#28a745',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
archiveResultsText: {
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SettingsScreen;
|
||||||
530
src/utils/articleUtils.ts
Normal file
530
src/utils/articleUtils.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
htmlContent?: string;
|
||||||
|
archivedAt: string;
|
||||||
|
type: 'link' | 'html';
|
||||||
|
source?: 'manual' | 'archive_api';
|
||||||
|
timestamp?: string;
|
||||||
|
isRead?: boolean;
|
||||||
|
readAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches HTML content from a URL
|
||||||
|
*/
|
||||||
|
export const fetchHtmlFromUrl = async (url: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
return html;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching HTML:', error);
|
||||||
|
throw new Error(`Failed to fetch HTML from ${url}: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts title from HTML content
|
||||||
|
*/
|
||||||
|
export const extractTitleFromHtml = (html: string): string => {
|
||||||
|
try {
|
||||||
|
// Try to extract title from <title> tag
|
||||||
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||||
|
if (titleMatch && titleMatch[1]) {
|
||||||
|
return titleMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from h1 tag
|
||||||
|
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||||||
|
if (h1Match && h1Match[1]) {
|
||||||
|
return h1Match[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to URL domain
|
||||||
|
return 'Untitled Article';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting title:', error);
|
||||||
|
return 'Untitled Article';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an HTML article to local storage
|
||||||
|
*/
|
||||||
|
export const saveHtmlArticle = async (
|
||||||
|
url: string,
|
||||||
|
title?: string,
|
||||||
|
): Promise<Article> => {
|
||||||
|
try {
|
||||||
|
// Fetch HTML content
|
||||||
|
const htmlContent = await fetchHtmlFromUrl(url);
|
||||||
|
|
||||||
|
// Extract title if not provided
|
||||||
|
const extractedTitle = title || extractTitleFromHtml(htmlContent);
|
||||||
|
|
||||||
|
// Create article object
|
||||||
|
const article: Article = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: extractedTitle,
|
||||||
|
url: url,
|
||||||
|
htmlContent: htmlContent,
|
||||||
|
archivedAt: new Date().toISOString(),
|
||||||
|
type: 'html',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get existing articles
|
||||||
|
const existingArticles = await AsyncStorage.getItem('articles');
|
||||||
|
const articles: Article[] = existingArticles
|
||||||
|
? JSON.parse(existingArticles)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Add new article to the beginning
|
||||||
|
const updatedArticles = [article, ...articles];
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
|
||||||
|
|
||||||
|
return article;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving HTML article:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a regular link article to local storage
|
||||||
|
*/
|
||||||
|
export const saveLinkArticle = async (
|
||||||
|
url: string,
|
||||||
|
title?: string,
|
||||||
|
): Promise<Article> => {
|
||||||
|
try {
|
||||||
|
// Create article object
|
||||||
|
const article: Article = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: title || 'Untitled Article',
|
||||||
|
url: url,
|
||||||
|
archivedAt: new Date().toISOString(),
|
||||||
|
type: 'link',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get existing articles
|
||||||
|
const existingArticles = await AsyncStorage.getItem('articles');
|
||||||
|
const articles: Article[] = existingArticles
|
||||||
|
? JSON.parse(existingArticles)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Add new article to the beginning
|
||||||
|
const updatedArticles = [article, ...articles];
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
|
||||||
|
|
||||||
|
return article;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving link article:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all articles from local storage
|
||||||
|
*/
|
||||||
|
export const loadArticles = async (): Promise<Article[]> => {
|
||||||
|
try {
|
||||||
|
const storedArticles = await AsyncStorage.getItem('articles');
|
||||||
|
return storedArticles ? JSON.parse(storedArticles) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading articles:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an article from local storage
|
||||||
|
*/
|
||||||
|
export const deleteArticle = async (articleId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const articles = await loadArticles();
|
||||||
|
const updatedArticles = articles.filter(
|
||||||
|
article => article.id !== articleId,
|
||||||
|
);
|
||||||
|
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting article:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a URL is accessible and returns HTML
|
||||||
|
*/
|
||||||
|
export const validateHtmlUrl = async (url: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating URL:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a GET request with custom headers
|
||||||
|
*/
|
||||||
|
export const makeGetRequest = async (
|
||||||
|
url: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'X-ArchiveBox-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error making GET request:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches archive results from the API
|
||||||
|
*/
|
||||||
|
export const fetchArchiveResults = async (
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<any> => {
|
||||||
|
try {
|
||||||
|
// Use the provided parameters instead of hardcoded values
|
||||||
|
// Clean up the base URL (remove trailing slash and colon if present)
|
||||||
|
const cleanBaseUrl = baseUrl.replace(/[\/:]+$/, '');
|
||||||
|
|
||||||
|
// Try different possible API endpoints
|
||||||
|
const possibleEndpoints = [
|
||||||
|
`${cleanBaseUrl}/api/v1/core/archiveresults?limit=200&extractor=title`,
|
||||||
|
`${cleanBaseUrl}/api/archiveresults?limit=200&extractor=title`,
|
||||||
|
`${cleanBaseUrl}/archiveresults?limit=200&extractor=title`,
|
||||||
|
`${cleanBaseUrl}/api/v1/archiveresults?limit=200&extractor=title`,
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('=== API REQUEST DEBUG ===');
|
||||||
|
console.log('Original baseUrl:', baseUrl);
|
||||||
|
console.log('Cleaned baseUrl:', cleanBaseUrl);
|
||||||
|
console.log('Attempting to fetch from endpoints:', possibleEndpoints);
|
||||||
|
console.log('Using API key:', apiKey ? 'Present' : 'Missing');
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const endpoint of possibleEndpoints) {
|
||||||
|
try {
|
||||||
|
console.log(`\n🔗 Trying endpoint: ${endpoint}`);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-ArchiveBox-API-Key': apiKey,
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'User-Agent': 'PocketDog/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Response status: ${response.status}`);
|
||||||
|
console.log(
|
||||||
|
`📋 Response headers:`,
|
||||||
|
Object.fromEntries(response.headers.entries()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.log(`❌ Error response body: ${errorText}`);
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('🎉 Successfully fetched data:', data);
|
||||||
|
|
||||||
|
// Parse the results and download HTML content
|
||||||
|
const processedData = await processArchiveResults(data, cleanBaseUrl);
|
||||||
|
return processedData;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Failed to fetch from ${endpoint}:`, error);
|
||||||
|
lastError = error as Error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all endpoints failed, throw the last error
|
||||||
|
throw lastError || new Error('All API endpoints failed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('🚨 Error fetching archive results:', error);
|
||||||
|
|
||||||
|
// Provide more specific error messages with URL info
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new Error(
|
||||||
|
`Network error: Failed to reach ${baseUrl}. Tried endpoints: ${possibleEndpoints.join(
|
||||||
|
', ',
|
||||||
|
)}. Please check your internet connection and ensure the server is running.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message.includes('401')) {
|
||||||
|
throw new Error(
|
||||||
|
`Authentication failed: Please check your API key. Tried URL: ${baseUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message.includes('404')) {
|
||||||
|
throw new Error(
|
||||||
|
`API endpoint not found: Tried multiple endpoints on ${baseUrl}. Attempted URLs: ${possibleEndpoints.join(
|
||||||
|
', ',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch archive results from ${baseUrl}. Tried URLs: ${possibleEndpoints.join(
|
||||||
|
', ',
|
||||||
|
)}. Error: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes archive results and downloads HTML content
|
||||||
|
*/
|
||||||
|
const processArchiveResults = async (data: any, baseUrl: string) => {
|
||||||
|
try {
|
||||||
|
console.log('📊 Processing archive results...');
|
||||||
|
|
||||||
|
// Check if data has items array
|
||||||
|
const items = data.items || data.results || data.data || [];
|
||||||
|
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
console.log('⚠️ No items array found in response:', data);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
processedItems: [],
|
||||||
|
totalProcessed: 0,
|
||||||
|
downloadedArticles: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📦 Found ${items.length} items to process`);
|
||||||
|
|
||||||
|
const processedItems = [];
|
||||||
|
const downloadedArticles = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index++) {
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
|
// Extract timestamp from the item
|
||||||
|
const timestamp =
|
||||||
|
item.snapshot_timestamp ||
|
||||||
|
item.timestamp ||
|
||||||
|
item.date ||
|
||||||
|
item.created_at ||
|
||||||
|
item.archived_at;
|
||||||
|
|
||||||
|
if (!timestamp) {
|
||||||
|
console.log(`⚠️ No timestamp found for item ${index}:`, item);
|
||||||
|
processedItems.push({
|
||||||
|
...item,
|
||||||
|
downloadableUrl: null,
|
||||||
|
error: 'No timestamp found',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create downloadable URL using the format: {base_url}/archive/{timestamp}/mercury/content.html
|
||||||
|
const downloadableUrl = `${baseUrl}/archive/${timestamp}/mercury/content.html`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔗 Item ${index}: ${
|
||||||
|
item.title || item.url || 'Untitled'
|
||||||
|
} -> ${downloadableUrl}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const processedItem = {
|
||||||
|
...item,
|
||||||
|
downloadableUrl,
|
||||||
|
originalUrl: item.url || item.original_url || item.link,
|
||||||
|
title: item.output || item.title || item.name || 'Untitled',
|
||||||
|
timestamp: timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
processedItems.push(processedItem);
|
||||||
|
|
||||||
|
// Download the HTML content using snapshot_timestamp
|
||||||
|
try {
|
||||||
|
console.log(`📥 Downloading HTML for item ${index}...`);
|
||||||
|
const htmlContent = await fetchHtmlFromUrl(downloadableUrl);
|
||||||
|
|
||||||
|
// Create article object
|
||||||
|
const article: Article = {
|
||||||
|
id: `archive_${timestamp}_${index}`,
|
||||||
|
title: processedItem.title,
|
||||||
|
url: processedItem.originalUrl,
|
||||||
|
htmlContent: htmlContent,
|
||||||
|
archivedAt: new Date().toISOString(),
|
||||||
|
type: 'html',
|
||||||
|
source: 'archive_api',
|
||||||
|
timestamp: timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to local storage
|
||||||
|
await saveArticleToStorage(article);
|
||||||
|
downloadedArticles.push(article);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Successfully downloaded and saved article: ${article.title}`,
|
||||||
|
);
|
||||||
|
} catch (downloadError) {
|
||||||
|
console.log(
|
||||||
|
`❌ Failed to download HTML for item ${index}:`,
|
||||||
|
downloadError,
|
||||||
|
);
|
||||||
|
processedItem.downloadError = downloadError.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Successfully processed ${processedItems.length} items and downloaded ${downloadedArticles.length} articles`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
processedItems,
|
||||||
|
totalProcessed: processedItems.length,
|
||||||
|
downloadedArticles,
|
||||||
|
totalDownloaded: downloadedArticles.length,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error processing archive results:', error);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
processedItems: [],
|
||||||
|
totalProcessed: 0,
|
||||||
|
downloadedArticles: [],
|
||||||
|
totalDownloaded: 0,
|
||||||
|
error: `Processing error: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an article to local storage
|
||||||
|
*/
|
||||||
|
const saveArticleToStorage = async (article: Article): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Get existing articles
|
||||||
|
const existingArticles = await AsyncStorage.getItem('articles');
|
||||||
|
const articles: Article[] = existingArticles
|
||||||
|
? JSON.parse(existingArticles)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Check if article already exists (by ID)
|
||||||
|
const existingIndex = articles.findIndex(a => a.id === article.id);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Update existing article
|
||||||
|
articles[existingIndex] = article;
|
||||||
|
} else {
|
||||||
|
// Add new article to the beginning
|
||||||
|
articles.unshift(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
await AsyncStorage.setItem('articles', JSON.stringify(articles));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving article to storage:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an article as read
|
||||||
|
*/
|
||||||
|
export const markArticleAsRead = async (articleId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const articles = await loadArticles();
|
||||||
|
const updatedArticles = articles.map(article => {
|
||||||
|
if (article.id === articleId) {
|
||||||
|
return {
|
||||||
|
...article,
|
||||||
|
isRead: true,
|
||||||
|
readAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return article;
|
||||||
|
});
|
||||||
|
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking article as read:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an article as unread
|
||||||
|
*/
|
||||||
|
export const markArticleAsUnread = async (articleId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const articles = await loadArticles();
|
||||||
|
const updatedArticles = articles.map(article => {
|
||||||
|
if (article.id === articleId) {
|
||||||
|
return {
|
||||||
|
...article,
|
||||||
|
isRead: false,
|
||||||
|
readAt: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return article;
|
||||||
|
});
|
||||||
|
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking article as unread:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets read statistics
|
||||||
|
*/
|
||||||
|
export const getReadStats = async (): Promise<{
|
||||||
|
total: number;
|
||||||
|
read: number;
|
||||||
|
unread: number;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const articles = await loadArticles();
|
||||||
|
const total = articles.length;
|
||||||
|
const read = articles.filter(article => article.isRead).length;
|
||||||
|
const unread = total - read;
|
||||||
|
|
||||||
|
return { total, read, unread };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting read stats:', error);
|
||||||
|
return { total: 0, read: 0, unread: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user